diff --git a/package-lock.json b/package-lock.json index 606086c35..2eca49edd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3670,6 +3670,11 @@ "integrity": "sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q==", "dev": true }, + "abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" + }, "accepts": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", @@ -3742,7 +3747,6 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3815,6 +3819,49 @@ } } }, + "aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==" + }, + "are-we-there-yet": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz", + "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "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==" + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -3906,7 +3953,6 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", - "dev": true, "requires": { "safer-buffer": "~2.1.0" } @@ -3929,13 +3975,19 @@ "integrity": "sha512-yG89F0j9B4B0MKIcFyWWxnpZPLaNTjCj4tkE3fjbAoo0qmpGw0PYYqSbX/4ebnd9Icn8ZgK4K1fvDyEtW1JYtQ==", "requires": { "pvutils": "^1.0.17" + }, + "dependencies": { + "pvutils": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.0.17.tgz", + "integrity": "sha512-wLHYUQxWaXVQvKnwIDWFVKDJku9XDCvyhhxoq8dc5MFdIlRenyPI9eSfEtcvgHgD7FlvCyGAlWgOzRnZD99GZQ==" + } } }, "assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", - "dev": true + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" }, "assertion-error": { "version": "1.1.0", @@ -3980,8 +4032,7 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", - "dev": true + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, "at-least-node": { "version": "1.0.0", @@ -4007,14 +4058,12 @@ "aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", - "dev": true + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" }, "aws4": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz", - "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==", - "dev": true + "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==" }, "axios": { "version": "0.21.1", @@ -4448,8 +4497,7 @@ "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" }, "base": { "version": "0.11.2", @@ -4515,7 +4563,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", - "dev": true, "requires": { "tweetnacl": "^0.14.3" } @@ -4548,6 +4595,15 @@ "dev": true, "optional": true }, + "block-stream": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz", + "integrity": "sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=", + "optional": true, + "requires": { + "inherits": "~2.0.0" + } + }, "bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -4604,7 +4660,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4890,8 +4945,7 @@ "caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", - "dev": true + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" }, "chai-nightwatch": { "version": "0.4.0", @@ -5171,6 +5225,11 @@ "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", "dev": true }, + "code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" + }, "collect-v8-coverage": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", @@ -5218,7 +5277,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "requires": { "delayed-stream": "~1.0.0" } @@ -5265,8 +5323,7 @@ "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, "confusing-browser-globals": { "version": "1.0.10", @@ -5274,6 +5331,11 @@ "integrity": "sha512-gNld/3lySHwuhaVluJUKLePYirM3QNCKzVxqAdhJII9/WXKVX5PURzMVJspS1jTslSqjeuG4KMVTSouit5YPHA==", "dev": true }, + "console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" + }, "contains-path": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz", @@ -5406,8 +5468,7 @@ "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", - "dev": true + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, "cosmiconfig": { "version": "7.0.0", @@ -5545,7 +5606,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", - "dev": true, "requires": { "assert-plus": "^1.0.0" } @@ -5634,6 +5694,11 @@ } } }, + "deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==" + }, "deep-is": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", @@ -5743,8 +5808,12 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", - "dev": true + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=" }, "depd": { "version": "1.1.2", @@ -5768,6 +5837,11 @@ "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=", "dev": true }, + "detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=" + }, "detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -5889,7 +5963,6 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", - "dev": true, "requires": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" @@ -5983,6 +6056,11 @@ "ansi-colors": "^4.1.1" } }, + "env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==" + }, "envinfo": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.8.1.tgz", @@ -6907,8 +6985,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", @@ -7011,14 +7088,12 @@ "extsprintf": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", - "dev": true + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "fast-glob": { "version": "3.2.5", @@ -7091,8 +7166,7 @@ "fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, "fast-levenshtein": { "version": "2.0.6", @@ -7304,14 +7378,12 @@ "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", - "dev": true + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" }, "form-data": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "dev": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.6", @@ -7368,8 +7440,7 @@ "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, "fsevents": { "version": "2.3.2", @@ -7378,6 +7449,38 @@ "dev": true, "optional": true }, + "fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "optional": true, + "requires": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "dependencies": { + "mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "optional": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "optional": true, + "requires": { + "glob": "^7.1.3" + } + } + } + }, "ftp": { "version": "0.3.10", "resolved": "https://registry.npmjs.org/ftp/-/ftp-0.3.10.tgz", @@ -7425,6 +7528,41 @@ "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", "dev": true }, + "gauge": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + }, + "dependencies": { + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + } + } + }, "geckodriver": { "version": "1.22.3", "resolved": "https://registry.npmjs.org/geckodriver/-/geckodriver-1.22.3.tgz", @@ -7505,7 +7643,6 @@ "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", - "dev": true, "requires": { "assert-plus": "^1.0.0" } @@ -7533,7 +7670,6 @@ "version": "7.1.6", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", - "dev": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -7668,8 +7804,7 @@ "graceful-fs": { "version": "4.2.6", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz", - "integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==", - "dev": true + "integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==" }, "growl": { "version": "1.10.5", @@ -7717,14 +7852,12 @@ "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", - "dev": true + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" }, "har-validator": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", - "dev": true, "requires": { "ajv": "^6.12.3", "har-schema": "^2.0.0" @@ -7772,6 +7905,11 @@ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==" }, + "has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=" + }, "has-value": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", @@ -7896,7 +8034,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", - "dev": true, "requires": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", @@ -7929,11 +8066,15 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, "requires": { "safer-buffer": ">= 2.1.2 < 3" } }, + "idb-keyval": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-5.0.5.tgz", + "integrity": "sha512-cqi65rrjhgPExI9vmSU7VcYEbHCUfIBY+9YUWxyr0PyGizptFgGFnvZQ0w+tqOXk1lUcGCZGVLfabf7QnR2S0g==" + }, "ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -7945,6 +8086,14 @@ "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==", "dev": true }, + "ignore-walk": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.3.tgz", + "integrity": "sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw==", + "requires": { + "minimatch": "^3.0.4" + } + }, "import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -7989,7 +8138,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, "requires": { "once": "^1.3.0", "wrappy": "1" @@ -8003,8 +8151,7 @@ "ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" }, "interpret": { "version": "1.4.0", @@ -8344,8 +8491,7 @@ "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", - "dev": true + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" }, "is-url": { "version": "1.2.4", @@ -8383,14 +8529,12 @@ "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" }, "isobject": { "version": "3.0.1", @@ -8401,8 +8545,7 @@ "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", - "dev": true + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" }, "istanbul-lib-coverage": { "version": "3.0.0", @@ -10074,8 +10217,7 @@ "jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", - "dev": true + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" }, "jsdom": { "version": "16.4.0", @@ -10132,14 +10274,12 @@ "json-schema": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", - "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", - "dev": true + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, "json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -10150,8 +10290,7 @@ "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", - "dev": true + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" }, "json5": { "version": "2.2.0", @@ -10181,7 +10320,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", - "dev": true, "requires": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", @@ -10817,14 +10955,12 @@ "mime-db": { "version": "1.46.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.46.0.tgz", - "integrity": "sha512-svXaP8UQRZ5K7or+ZmfNhg2xX3yKDMUzqadsSqi4NCH/KomcH75MAMYAGVlvXn4+b/xOPhS3I2uHKRUzvjY7BQ==", - "dev": true + "integrity": "sha512-svXaP8UQRZ5K7or+ZmfNhg2xX3yKDMUzqadsSqi4NCH/KomcH75MAMYAGVlvXn4+b/xOPhS3I2uHKRUzvjY7BQ==" }, "mime-types": { "version": "2.1.29", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.29.tgz", "integrity": "sha512-Y/jMt/S5sR9OaqteJtslsFZKWOIIqMACsJSiHghlCAyhf7jfVYjKBmLiX8OgpWeW+fjJ2b+Az69aPFPkUOY6xQ==", - "dev": true, "requires": { "mime-db": "1.46.0" } @@ -10854,7 +10990,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "dev": true, "requires": { "brace-expansion": "^1.1.7" } @@ -10862,8 +10997,7 @@ "minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", - "dev": true + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" }, "minimist-options": { "version": "4.1.0", @@ -11222,6 +11356,26 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "needle": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/needle/-/needle-2.6.0.tgz", + "integrity": "sha512-KKYdza4heMsEfSWD7VPUIz3zX2XDwOyX2d+geb4vrERZMT5RMU6ujjaD+I5Yr54uZxQ2w6XRTAhHBbSCyovZBg==", + "requires": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + }, + "dependencies": { + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "requires": { + "ms": "^2.1.1" + } + } + } + }, "negotiator": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", @@ -11345,6 +11499,72 @@ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" }, + "node-gyp": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-3.8.0.tgz", + "integrity": "sha512-3g8lYefrRRzvGeSowdJKAKyks8oUpLEd/DyPV4eMhVlhJ0aNaZqIrNUIPuEWWTAoPqyFkfGrM67MC69baqn6vA==", + "optional": true, + "requires": { + "fstream": "^1.0.0", + "glob": "^7.0.3", + "graceful-fs": "^4.1.2", + "mkdirp": "^0.5.0", + "nopt": "2 || 3", + "npmlog": "0 || 1 || 2 || 3 || 4", + "osenv": "0", + "request": "^2.87.0", + "rimraf": "2", + "semver": "~5.3.0", + "tar": "^2.0.0", + "which": "1" + }, + "dependencies": { + "mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "optional": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "optional": true, + "requires": { + "glob": "^7.1.3" + } + }, + "semver": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", + "integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8=", + "optional": true + }, + "tar": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.2.tgz", + "integrity": "sha512-FCEhQ/4rE1zYv9rYXJw/msRqsnmlje5jHP6huWeBZ704jUTy02c5AZyWujpMR1ax6mVw9NyJMfuK2CMDWVIfgA==", + "optional": true, + "requires": { + "block-stream": "*", + "fstream": "^1.0.12", + "inherits": "2" + } + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "optional": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, "node-gyp-build": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.2.3.tgz", @@ -11389,6 +11609,99 @@ } } }, + "node-pre-gyp": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.11.0.tgz", + "integrity": "sha512-TwWAOZb0j7e9eGaf9esRx3ZcLaE5tQ2lvYy1pb5IAaG1a2e2Kv5Lms1Y4hpj+ciXJRofIxxlt5haeQ/2ANeE0Q==", + "requires": { + "detect-libc": "^1.0.2", + "mkdirp": "^0.5.1", + "needle": "^2.2.1", + "nopt": "^4.0.1", + "npm-packlist": "^1.1.6", + "npmlog": "^4.0.2", + "rc": "^1.2.7", + "rimraf": "^2.6.1", + "semver": "^5.3.0", + "tar": "^4" + }, + "dependencies": { + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "fs-minipass": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz", + "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==", + "requires": { + "minipass": "^2.6.0" + } + }, + "minipass": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz", + "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==", + "requires": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + }, + "minizlib": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz", + "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==", + "requires": { + "minipass": "^2.9.0" + } + }, + "mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "requires": { + "minimist": "^1.2.5" + } + }, + "nopt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz", + "integrity": "sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==", + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + }, + "rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "requires": { + "glob": "^7.1.3" + } + }, + "tar": { + "version": "4.4.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.13.tgz", + "integrity": "sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA==", + "requires": { + "chownr": "^1.1.1", + "fs-minipass": "^1.2.5", + "minipass": "^2.8.6", + "minizlib": "^1.2.1", + "mkdirp": "^0.5.0", + "safe-buffer": "^5.1.2", + "yallist": "^3.0.3" + } + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + } + } + }, "node-releases": { "version": "1.1.71", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.71.tgz", @@ -11413,6 +11726,15 @@ "webcrypto-core": "^1.1.6" } }, + "nopt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", + "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", + "optional": true, + "requires": { + "abbrev": "1" + } + }, "normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -11439,6 +11761,29 @@ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true }, + "npm-bundled": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.2.tgz", + "integrity": "sha512-x5DHup0SuyQcmL3s7Rx/YQ8sbw/Hzg0rj48eN0dV7hf5cmQq5PXIeioroH3raV1QC1yh3uTYuMThvEQF3iKgGQ==", + "requires": { + "npm-normalize-package-bin": "^1.0.1" + } + }, + "npm-normalize-package-bin": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz", + "integrity": "sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==" + }, + "npm-packlist": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.8.tgz", + "integrity": "sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==", + "requires": { + "ignore-walk": "^3.0.1", + "npm-bundled": "^1.0.1", + "npm-normalize-package-bin": "^1.0.1" + } + }, "npm-run-path": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", @@ -11456,6 +11801,22 @@ } } }, + "npmlog": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" + }, "nwsapi": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz", @@ -11465,14 +11826,12 @@ "oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", - "dev": true + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", - "dev": true + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" }, "object-copy": { "version": "0.1.0", @@ -11804,6 +12163,25 @@ } } }, + "os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=" + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=" + }, + "osenv": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", + "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, "p-defer": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", @@ -11993,8 +12371,7 @@ "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, "path-key": { "version": "3.1.1", @@ -12042,8 +12419,7 @@ "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", - "dev": true + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" }, "picomatch": { "version": "2.2.2", @@ -12167,8 +12543,7 @@ "process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, "progress": { "version": "2.0.3", @@ -12249,8 +12624,7 @@ "psl": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", - "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==", - "dev": true + "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" }, "public-encrypt": { "version": "4.0.3", @@ -12279,8 +12653,7 @@ "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, "pvtsutils": { "version": "1.1.2", @@ -12290,11 +12663,6 @@ "tslib": "^2.1.0" } }, - "pvutils": { - "version": "1.0.17", - "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.0.17.tgz", - "integrity": "sha512-wLHYUQxWaXVQvKnwIDWFVKDJku9XDCvyhhxoq8dc5MFdIlRenyPI9eSfEtcvgHgD7FlvCyGAlWgOzRnZD99GZQ==" - }, "q": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", @@ -12357,6 +12725,24 @@ "unpipe": "1.0.0" } }, + "rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=" + } + } + }, "react-is": { "version": "17.0.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz", @@ -12674,7 +13060,6 @@ "version": "2.88.2", "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", - "dev": true, "requires": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", @@ -12701,14 +13086,12 @@ "qs": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", - "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", - "dev": true + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" }, "tough-cookie": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "dev": true, "requires": { "psl": "^1.1.28", "punycode": "^2.1.1" @@ -12717,8 +13100,7 @@ "uuid": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "dev": true + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" } } }, @@ -12923,8 +13305,7 @@ "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "sane": { "version": "4.1.0", @@ -12943,6 +13324,11 @@ "walker": "~1.0.5" } }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + }, "saxes": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", @@ -12981,8 +13367,7 @@ "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", - "dev": true + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" }, "send": { "version": "0.17.1", @@ -13054,8 +13439,7 @@ "set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", - "dev": true + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" }, "set-value": { "version": "2.0.1", @@ -13169,8 +13553,7 @@ "signal-exit": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", - "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", - "dev": true + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" }, "sinon": { "version": "9.2.4", @@ -13531,11 +13914,32 @@ "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", "dev": true }, + "sqlite": { + "version": "4.0.21", + "resolved": "https://registry.npmjs.org/sqlite/-/sqlite-4.0.21.tgz", + "integrity": "sha512-HIqObuvz+Vx8BXWzIhR12fJMjiE37Mdfupg2Ok0f8MChSqALXj7FgpZauj1pJoSY6qsDYmp/+/ZgSn3l8yutoA==" + }, + "sqlite3": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.0.2.tgz", + "integrity": "sha512-1SdTNo+BVU211Xj1csWa8lV6KM0CtucDwRyA0VHl91wEH1Mgh7RxUpI4rVvG7OhHrzCSGaVyW5g8vKvlrk9DJA==", + "requires": { + "node-addon-api": "^3.0.0", + "node-gyp": "3.x", + "node-pre-gyp": "^0.11.0" + }, + "dependencies": { + "node-addon-api": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.1.0.tgz", + "integrity": "sha512-flmrDNB06LIl5lywUz7YlNGZH/5p0M7W28k8hzd9Lshtdh1wshD2Y+U4h9LD6KObOy1f+fEVdgprPrEymjM5uw==" + } + } + }, "sshpk": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", - "dev": true, "requires": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", @@ -13700,7 +14104,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", - "dev": true, "requires": { "ansi-regex": "^2.0.0" }, @@ -13708,8 +14111,7 @@ "ansi-regex": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "dev": true + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" } } }, @@ -14319,7 +14721,6 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", - "dev": true, "requires": { "safe-buffer": "^5.0.1" } @@ -14327,8 +14728,7 @@ "tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", - "dev": true + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" }, "type-check": { "version": "0.4.0", @@ -14547,7 +14947,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "requires": { "punycode": "^2.1.0" } @@ -14657,7 +15056,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", - "dev": true, "requires": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", @@ -15068,7 +15466,6 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", - "dev": true, "requires": { "string-width": "^1.0.2 || 2" }, @@ -15076,20 +15473,17 @@ "ansi-regex": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", - "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", - "dev": true + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=" }, "is-fullwidth-code-point": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", - "dev": true + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" }, "string-width": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", - "dev": true, "requires": { "is-fullwidth-code-point": "^2.0.0", "strip-ansi": "^4.0.0" @@ -15099,7 +15493,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", - "dev": true, "requires": { "ansi-regex": "^3.0.0" } diff --git a/package.json b/package.json index 737ef0a8b..a8e2ae48e 100644 --- a/package.json +++ b/package.json @@ -151,7 +151,9 @@ "@ethersproject/wallet": "^5.1.0", "@ethersproject/web": "^5.1.0", "debug": "^4.3.2", + "env-paths": "^2.2.1", "eventemitter3": "^4.0.7", + "idb-keyval": "^5.0.5", "lodash": "^4.17.21", "mem": "^8.1.1", "node-abort-controller": "^1.2.1", @@ -165,6 +167,8 @@ "qs": "^6.10.1", "quick-lru": "^6.0.0", "readable-stream": "^3.6.0", + "sqlite": "^4.0.21", + "sqlite3": "^5.0.2", "streamr-client-protocol": "^8.0.2", "streamr-test-utils": "^1.3.2", "ts-toolbelt": "^9.6.0", diff --git a/src/Connection.ts b/src/Connection.ts index 92c3fdb52..84bf7040a 100644 --- a/src/Connection.ts +++ b/src/Connection.ts @@ -425,7 +425,7 @@ export default class Connection extends EventEmitter { } } - enableAutoDisconnect(autoDisconnect = true) { + enableAutoDisconnect(autoDisconnect: boolean | number = true) { let delay if (typeof autoDisconnect === 'number') { delay = autoDisconnect @@ -443,7 +443,7 @@ export default class Connection extends EventEmitter { } } - enableAutoConnect(autoConnect = true) { + enableAutoConnect(autoConnect: boolean | number = true) { autoConnect = !!autoConnect // eslint-disable-line no-param-reassign if (this.options.autoConnect && !autoConnect) { this.didDisableAutoConnect = true diff --git a/src/StreamrClient.ts b/src/StreamrClient.ts index 365da657f..9a60cdb09 100644 --- a/src/StreamrClient.ts +++ b/src/StreamrClient.ts @@ -22,8 +22,7 @@ import { DataUnion, DataUnionDeployOptions } from './dataunion/DataUnion' import { BigNumber } from '@ethersproject/bignumber' import { getAddress } from '@ethersproject/address' import { Contract } from '@ethersproject/contracts' -import { StreamPartDefinition } from './stream' -import type { GroupKey } from './stream/Encryption' +import { StreamPartDefinition, GroupKey } from './stream' // TODO get metadata type from streamr-protocol-js project (it doesn't export the type definitions yet) export type OnMessageCallback = MaybeAsync<(message: any, metadata: any) => void> diff --git a/src/index.ts b/src/index.ts index 82cd3e739..06893b57f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,7 @@ import { StreamrClient } from './StreamrClient' export * from './StreamrClient' export * from './Config' export * from './stream' -export * from './stream/Encryption' +export * from './stream/encryption/Encryption' export * from './stream/StreamPart' export * from './stream/StorageNode' export * from './subscribe' diff --git a/src/publish/Encrypt.ts b/src/publish/Encrypt.ts index ebee57bae..5b8492aaf 100644 --- a/src/publish/Encrypt.ts +++ b/src/publish/Encrypt.ts @@ -1,20 +1,18 @@ import { MessageLayer } from 'streamr-client-protocol' -import EncryptionUtil from '../stream/Encryption' +import EncryptionUtil from '../stream/encryption/Encryption' import { Stream } from '../stream' import { StreamrClient } from '../StreamrClient' -import { PublisherKeyExhange } from '../stream/KeyExchange' +import { PublisherKeyExhange } from '../stream/encryption/KeyExchangePublisher' const { StreamMessage } = MessageLayer -type PublisherKeyExhangeAPI = ReturnType - export default function Encrypt(client: StreamrClient) { - let publisherKeyExchange: ReturnType + let publisherKeyExchange: PublisherKeyExhange function getPublisherKeyExchange() { if (!publisherKeyExchange) { - publisherKeyExchange = PublisherKeyExhange(client, { + publisherKeyExchange = new PublisherKeyExhange(client, { groupKeys: { ...client.options.groupKeys, } @@ -28,9 +26,19 @@ export default function Encrypt(client: StreamrClient) { return } + const { messageType } = streamMessage + if ( + messageType === StreamMessage.MESSAGE_TYPES.GROUP_KEY_RESPONSE + || messageType === StreamMessage.MESSAGE_TYPES.GROUP_KEY_REQUEST + || messageType === StreamMessage.MESSAGE_TYPES.GROUP_KEY_ERROR_RESPONSE + ) { + // never encrypt + return + } + if ( !stream.requireEncryptedData - && !getPublisherKeyExchange().hasAnyGroupKey(stream.id) + && !(await (getPublisherKeyExchange().hasAnyGroupKey(stream.id))) ) { // not needed return @@ -41,17 +49,21 @@ export default function Encrypt(client: StreamrClient) { } const [groupKey, nextGroupKey] = await getPublisherKeyExchange().useGroupKey(stream.id) + if (!groupKey) { + throw new Error(`Tried to use group key but no group key found for stream: ${stream.id}`) + } + await EncryptionUtil.encryptStreamMessage(streamMessage, groupKey, nextGroupKey) } return Object.assign(encrypt, { - setNextGroupKey(...args: Parameters) { + setNextGroupKey(...args: Parameters) { return getPublisherKeyExchange().setNextGroupKey(...args) }, - rotateGroupKey(...args: Parameters) { + rotateGroupKey(...args: Parameters) { return getPublisherKeyExchange().rotateGroupKey(...args) }, - rekey(...args: Parameters) { + rekey(...args: Parameters) { return getPublisherKeyExchange().rekey(...args) }, start() { diff --git a/src/rest/StreamEndpoints.ts b/src/rest/StreamEndpoints.ts index ca237af00..972afb905 100644 --- a/src/rest/StreamEndpoints.ts +++ b/src/rest/StreamEndpoints.ts @@ -8,7 +8,7 @@ import { getEndpointUrl } from '../utils' import { validateOptions } from '../stream/utils' import { Stream, StreamOperation, StreamProperties } from '../stream' import { StreamPart } from '../stream/StreamPart' -import { isKeyExchangeStream } from '../stream/KeyExchange' +import { isKeyExchangeStream } from '../stream/encryption/KeyExchangeUtils' import authFetch, { ErrorCode, NotFoundError } from './authFetch' import { EthereumAddress } from '../types' diff --git a/src/stream/KeyExchange.js b/src/stream/KeyExchange.js deleted file mode 100644 index 778f78d30..000000000 --- a/src/stream/KeyExchange.js +++ /dev/null @@ -1,600 +0,0 @@ -import { MessageLayer, Errors } from 'streamr-client-protocol' -import mem from 'mem' - -import { uuid, Defer } from '../utils' -import Scaffold from '../utils/Scaffold' - -import { validateOptions } from './utils' -import EncryptionUtil, { GroupKey } from './Encryption' - -const { - StreamMessage, GroupKeyRequest, GroupKeyResponse, GroupKeyErrorResponse, EncryptedGroupKey -} = MessageLayer - -const KEY_EXCHANGE_STREAM_PREFIX = 'SYSTEM/keyexchange' - -const { ValidationError } = Errors - -export function isKeyExchangeStream(id = '') { - return id.startsWith(KEY_EXCHANGE_STREAM_PREFIX) -} - -class InvalidGroupKeyRequestError extends ValidationError { - constructor(...args) { - super(...args) - this.code = 'INVALID_GROUP_KEY_REQUEST' - if (Error.captureStackTrace) { - Error.captureStackTrace(this, this.constructor) - } - } -} - -/* -class InvalidGroupKeyResponseError extends Error { - constructor(...args) { - super(...args) - this.code = 'INVALID_GROUP_KEY_RESPONSE' - if (Error.captureStackTrace) { - Error.captureStackTrace(this, this.constructor) - } - } -} - -class InvalidContentTypeError extends Error { - constructor(...args) { - super(...args) - this.code = 'INVALID_MESSAGE_TYPE' - if (Error.captureStackTrace) { - Error.captureStackTrace(this, this.constructor) - } - } -} -*/ - -export function getKeyExchangeStreamId(address) { - if (isKeyExchangeStream(address)) { - return address // prevent ever double-handling - } - return `${KEY_EXCHANGE_STREAM_PREFIX}/${address.toLowerCase()}` -} - -function GroupKeyStore({ groupKeys = new Map() }) { - const store = new Map() - groupKeys.forEach((value, key) => { - store.set(key, value) - }) - - let currentGroupKeyId // current key id if any - const nextGroupKeys = [] // the keys to use next, disappears if not actually used. Max queue size 2 - - store.forEach((groupKey) => { - GroupKey.validate(GroupKey.from(groupKey)) - // use last init key as current - currentGroupKeyId = groupKey.id - }) - - function storeKey(groupKey) { - GroupKey.validate(groupKey) - if (store.has(groupKey.id)) { - const existingKey = GroupKey.from(store.get(groupKey.id)) - if (!existingKey.equals(groupKey)) { - throw new GroupKey.InvalidGroupKeyError( - `Trying to add groupKey ${groupKey.id} but key exists & is not equivalent to new GroupKey: ${groupKey}.`, - groupKey - ) - } - - store.delete(groupKey.id) // sort key at end by deleting existing entry before re-adding - store.set(groupKey.id, existingKey) // reuse existing instance - return existingKey - } - - store.set(groupKey.id, groupKey) - return groupKey - } - - return { - has(groupKeyId) { - if (currentGroupKeyId === groupKeyId) { return true } - - if (nextGroupKeys.some((nextKey) => nextKey.id === groupKeyId)) { return true } - - return store.has(groupKeyId) - }, - isEmpty() { - return nextGroupKeys.length === 0 && store.size === 0 - }, - useGroupKey() { - const nextGroupKey = nextGroupKeys.pop() - switch (true) { - // First use of group key on this stream, no current key. Make next key current. - case !!(!currentGroupKeyId && nextGroupKey): { - storeKey(nextGroupKey) - currentGroupKeyId = nextGroupKey.id - return [ - this.get(currentGroupKeyId), - undefined, - ] - } - // Keep using current key (empty next) - case !!(currentGroupKeyId && !nextGroupKey): { - return [ - this.get(currentGroupKeyId), - undefined - ] - } - // Key changed (non-empty next). return current + next. Make next key current. - case !!(currentGroupKeyId && nextGroupKey): { - storeKey(nextGroupKey) - const prevGroupKey = this.get(currentGroupKeyId) - currentGroupKeyId = nextGroupKey.id - // use current key one more time - return [ - prevGroupKey, - nextGroupKey, - ] - } - // Generate & use new key if none already set. - default: { - this.rotateGroupKey() - return this.useGroupKey() - } - } - }, - get(groupKeyId) { - const groupKey = store.get(groupKeyId) - if (!groupKey) { return undefined } - return GroupKey.from(groupKey) - }, - clear() { - currentGroupKeyId = undefined - nextGroupKeys.length = 0 - return store.clear() - }, - rotateGroupKey() { - return this.setNextGroupKey(GroupKey.generate()) - }, - add(groupKey) { - return storeKey(groupKey) - }, - setNextGroupKey(newKey) { - GroupKey.validate(newKey) - nextGroupKeys.unshift(newKey) - nextGroupKeys.length = Math.min(nextGroupKeys.length, 2) - }, - rekey() { - const newKey = GroupKey.generate() - storeKey(newKey) - currentGroupKeyId = newKey.id - nextGroupKeys.length = 0 - } - } -} - -function parseGroupKeys(groupKeys = {}) { - return new Map(Object.entries(groupKeys || {}).map(([key, value]) => { - if (!value || !key) { return null } - return [key, GroupKey.from(value)] - }).filter(Boolean)) -} - -function waitForSubMessage(sub, matchFn) { - const task = Defer() - const onMessage = (content, streamMessage) => { - try { - if (matchFn(content, streamMessage)) { - task.resolve(streamMessage) - } - } catch (err) { - task.reject(err) - } - } - sub.on('message', onMessage) - sub.once('error', task.reject) - task.finally(() => { - sub.off('message', onMessage) - sub.off('error', task.reject) - }).catch(() => {}) // prevent unhandled rejection - return task -} - -async function subscribeToKeyExchangeStream(client, onKeyExchangeMessage) { - const { options } = client - if ((!options.auth.privateKey && !options.auth.ethereum) || !options.keyExchange) { - return Promise.resolve() - } - - await client.session.getSessionToken() // trigger auth errors if any - // subscribing to own keyexchange stream - const publisherId = await client.getUserId() - const streamId = getKeyExchangeStreamId(publisherId) - const sub = await client.subscribe(streamId, onKeyExchangeMessage) - sub.on('error', () => {}) // errors should not shut down subscription - return sub -} - -async function catchKeyExchangeError(client, streamMessage, fn) { - try { - return await fn() - } catch (error) { - const subscriberId = streamMessage.getPublisherId() - const msg = streamMessage.getParsedContent() - const { streamId, requestId, groupKeyIds } = GroupKeyRequest.fromArray(msg) - return client.publish(getKeyExchangeStreamId(subscriberId), new GroupKeyErrorResponse({ - requestId, - streamId, - errorCode: error.code || 'UNEXPECTED_ERROR', - errorMessage: error.message, - groupKeyIds - })) - } -} - -async function PublisherKeyExhangeSubscription(client, getGroupKeyStore) { - async function onKeyExchangeMessage(_parsedContent, streamMessage) { - return catchKeyExchangeError(client, streamMessage, async () => { - if (streamMessage.messageType !== StreamMessage.MESSAGE_TYPES.GROUP_KEY_REQUEST) { - return Promise.resolve() - } - - // No need to check if parsedContent contains the necessary fields because it was already checked during deserialization - const { requestId, streamId, rsaPublicKey, groupKeyIds } = GroupKeyRequest.fromArray(streamMessage.getParsedContent()) - - const subscriberId = streamMessage.getPublisherId() - - const groupKeyStore = getGroupKeyStore(streamId) - const isSubscriber = await client.isStreamSubscriber(streamId, subscriberId) - const encryptedGroupKeys = !isSubscriber ? [] : groupKeyIds.map((id) => { - const groupKey = groupKeyStore.get(id) - if (!groupKey) { - return null // will be filtered out - } - - return new EncryptedGroupKey(id, EncryptionUtil.encryptWithPublicKey(groupKey.data, rsaPublicKey, true)) - }).filter(Boolean) - - client.debug('Publisher: Subscriber requested groupKeys: %d. Got: %d. %o', groupKeyIds.length, encryptedGroupKeys.length, { - subscriberId, - groupKeyIds, - responseKeys: encryptedGroupKeys.map(({ groupKeyId }) => groupKeyId), - }) - - const response = new GroupKeyResponse({ - streamId, - requestId, - encryptedGroupKeys, - encryptionType: StreamMessage.ENCRYPTION_TYPES.RSA, - }) - - // hack overriding toStreamMessage method to set correct encryption type - const toStreamMessage = response.toStreamMessage.bind(response) - response.toStreamMessage = (...args) => { - const msg = toStreamMessage(...args) - msg.encryptionType = StreamMessage.ENCRYPTION_TYPES.RSA - return msg - } - - return client.publish(getKeyExchangeStreamId(subscriberId), response) - }) - } - - const sub = await subscribeToKeyExchangeStream(client, onKeyExchangeMessage) - sub.on('error', (err) => { - if (!err.streamMessage) { - return // do nothing - } - - // wrap error and translate into ErrorResponse. - catchKeyExchangeError(client, err.streamMessage, () => { // eslint-disable-line promise/no-promise-in-callback - // rethrow so catchKeyExchangeError handles it - throw new InvalidGroupKeyRequestError(err.message, err.streamMessage) - }).catch((unexpectedError) => { - sub.emit('error', unexpectedError) - }) - }) - - return sub -} - -export function PublisherKeyExhange(client, { groupKeys = {} } = {}) { - let enabled = true - const getGroupKeyStore = mem((streamId) => GroupKeyStore({ - groupKeys: parseGroupKeys(groupKeys[streamId]) - }), { - cacheKey([maybeStreamId]) { - const { streamId } = validateOptions(maybeStreamId) - return streamId - } - }) - let sub - const next = Scaffold([ - async () => { - sub = await PublisherKeyExhangeSubscription(client, getGroupKeyStore) - return async () => { - if (!sub) { return } - const cancelTask = sub.cancel() - sub = undefined - await cancelTask - } - } - ], () => enabled) - - function rotateGroupKey(streamId) { - if (!enabled) { return } - const groupKeyStore = getGroupKeyStore(streamId) - groupKeyStore.rotateGroupKey() - } - - function setNextGroupKey(streamId, groupKey) { - if (!enabled) { return } - const groupKeyStore = getGroupKeyStore(streamId) - - groupKeyStore.setNextGroupKey(groupKey) - } - - async function useGroupKey(streamId) { - await next() - if (!enabled) { return undefined } - const groupKeyStore = getGroupKeyStore(streamId) - - return groupKeyStore.useGroupKey() - } - - function hasAnyGroupKey(streamId) { - const groupKeyStore = getGroupKeyStore(streamId) - return !groupKeyStore.isEmpty() - } - - async function rekey(streamId) { - if (!enabled) { return } - const groupKeyStore = getGroupKeyStore(streamId) - groupKeyStore.rekey() - await next() - } - - return { - setNextGroupKey, - useGroupKey, - rekey, - rotateGroupKey, - hasAnyGroupKey, - async start() { - enabled = true - return next() - }, - async stop() { - enabled = false - return next() - } - } -} - -async function getGroupKeysFromStreamMessage(streamMessage, encryptionUtil) { - const { encryptedGroupKeys } = GroupKeyResponse.fromArray(streamMessage.getParsedContent()) - return Promise.all(encryptedGroupKeys.map(async (encryptedGroupKey) => ( - new GroupKey(encryptedGroupKey.groupKeyId, await encryptionUtil.decryptWithPrivateKey(encryptedGroupKey.encryptedGroupKeyHex, true)) - ))) -} - -async function SubscriberKeyExhangeSubscription(client, getGroupKeyStore, encryptionUtil) { - let sub - async function onKeyExchangeMessage(_parsedContent, streamMessage) { - try { - const { messageType } = streamMessage - const { MESSAGE_TYPES } = StreamMessage - if (messageType !== MESSAGE_TYPES.GROUP_KEY_ANNOUNCE) { - return - } - - const groupKeys = await getGroupKeysFromStreamMessage(streamMessage, encryptionUtil) - const groupKeyStore = getGroupKeyStore(streamMessage.getStreamId()) - groupKeys.forEach((groupKey) => { - groupKeyStore.add(groupKey) - }) - } catch (err) { - sub.emit('error', err) - } - } - - sub = await subscribeToKeyExchangeStream(client, onKeyExchangeMessage) - sub.on('error', () => {}) // errors should not shut down subscription - return sub -} - -export function SubscriberKeyExchange(client, { groupKeys = {} } = {}) { - let enabled = true - const encryptionUtil = new EncryptionUtil(client.options.keyExchange) - - const getGroupKeyStore = mem((streamId) => GroupKeyStore({ - groupKeys: parseGroupKeys(groupKeys[streamId]) - }), { - cacheKey([maybeStreamId]) { - const { streamId } = validateOptions(maybeStreamId) - return streamId - } - }) - - let sub - - async function requestKeys({ streamId, publisherId, groupKeyIds }) { - let done = false - const requestId = uuid('GroupKeyRequest') - const rsaPublicKey = encryptionUtil.getPublicKey() - const keyExchangeStreamId = getKeyExchangeStreamId(publisherId) - let responseTask - let cancelTask - let receivedGroupKeys = [] - let response - const step = Scaffold([ - async () => { - cancelTask = Defer() - responseTask = waitForSubMessage(sub, (content, streamMessage) => { - const { messageType } = streamMessage - const matchesMessageType = ( - messageType === StreamMessage.MESSAGE_TYPES.GROUP_KEY_RESPONSE - || messageType === StreamMessage.MESSAGE_TYPES.GROUP_KEY_ERROR_RESPONSE - ) - - if (!matchesMessageType) { - return false - } - - const groupKeyResponse = GroupKeyResponse.fromArray(content) - return groupKeyResponse.requestId === requestId - }) - - cancelTask.then(responseTask.resolve).catch(responseTask.reject) - return () => { - cancelTask.resolve() - } - }, async () => { - const msg = new GroupKeyRequest({ - streamId, - requestId, - rsaPublicKey, - groupKeyIds, - }) - await client.publish(keyExchangeStreamId, msg) - }, async () => { - response = await responseTask - return () => { - response = undefined - } - }, async () => { - receivedGroupKeys = response ? await getGroupKeysFromStreamMessage(response, encryptionUtil) : [] - - return () => { - receivedGroupKeys = [] - } - }, - ], () => enabled && !done, { - id: `requestKeys.${requestId}`, - onChange(isGoingUp) { - if (!isGoingUp && cancelTask) { - cancelTask.resolve() - } - } - }) - - requestKeys.step = step - await step() - const keys = receivedGroupKeys.slice() - done = true - await step() - return keys - } - - const pending = new Map() - const getBuffer = mem(() => []) - const timeouts = {} - - async function getKey(streamMessage) { - const streamId = streamMessage.getStreamId() - const publisherId = streamMessage.getPublisherId() - const { groupKeyId } = streamMessage - if (!groupKeyId) { - return Promise.resolve() - } - const groupKeyStore = getGroupKeyStore(streamId) - if (groupKeyStore.has(groupKeyId)) { - return groupKeyStore.get(groupKeyId) - } - - if (pending.has(groupKeyId)) { - return pending.get(groupKeyId) - } - - const key = `${streamId}.${publisherId}` - const buffer = getBuffer(key) - buffer.push(groupKeyId) - pending.set(groupKeyId, Defer()) - - async function processBuffer() { - const currentBuffer = getBuffer(key) - const groupKeyIds = currentBuffer.slice() - currentBuffer.length = 0 - try { - const receivedGroupKeys = await requestKeys({ - streamId, - publisherId, - groupKeyIds, - }) - receivedGroupKeys.forEach((groupKey) => { - groupKeyStore.add(groupKey) - }) - groupKeyIds.forEach((id) => { - if (!pending.has(id)) { return } - const groupKey = groupKeyStore.get(id) - const task = pending.get(id) - pending.delete(id) - task.resolve(groupKey) - }) - } catch (err) { - groupKeyIds.forEach((id) => { - if (!pending.has(id)) { return } - const task = pending.get(id) - pending.delete(id) - task.reject(err) - }) - } - } - - if (!timeouts[key]) { - timeouts[key] = setTimeout(() => { - delete timeouts[key] - processBuffer() - }, 1000) - } - - return pending.get(groupKeyId) - } - - const next = Scaffold([ - async () => { - return encryptionUtil.onReady() - }, - async () => { - sub = await SubscriberKeyExhangeSubscription(client, getGroupKeyStore, encryptionUtil) - return async () => { - mem.clear(getGroupKeyStore) - if (!sub) { return } - const cancelTask = sub.cancel() - sub = undefined - await cancelTask - } - } - ], () => enabled, { - id: `SubscriberKeyExhangeSubscription.${client.id}`, - async onDone() { - // clean up requestKey - if (requestKeys.step) { - await requestKeys.step() - } - } - }) - - async function getGroupKey(streamMessage) { - if (!streamMessage.groupKeyId) { return [] } - await next() - if (!enabled) { return [] } - - return getKey(streamMessage) - } - - return Object.assign(getGroupKey, { - async start() { - enabled = true - return next() - }, - addNewKey(streamMessage) { - if (!streamMessage.newGroupKey) { return } - const streamId = streamMessage.getStreamId() - const groupKeyStore = getGroupKeyStore(streamId) - groupKeyStore.add(streamMessage.newGroupKey) - }, - async stop() { - enabled = false - return next() - } - }) -} diff --git a/src/stream/encryption/BrowserPersistentStore.ts b/src/stream/encryption/BrowserPersistentStore.ts new file mode 100644 index 000000000..9ed888532 --- /dev/null +++ b/src/stream/encryption/BrowserPersistentStore.ts @@ -0,0 +1,53 @@ +import { PersistentStore } from './GroupKeyStore' +import { get, set, del, clear, keys, createStore } from 'idb-keyval' + +export default class BrowserPersistentStore implements PersistentStore { + readonly clientId: string + readonly streamId: string + private store?: any + + constructor({ clientId, streamId }: { clientId: string, streamId: string }) { + this.streamId = encodeURIComponent(streamId) + this.clientId = encodeURIComponent(clientId) + this.store = createStore(`streamr-client::${clientId}::${streamId}`, 'GroupKeys') + } + + async has(key: string) { + const val = await this.get(key) + return val == null + } + + async get(key: string) { + return get(key, this.store) + } + + async set(key: string, value: string) { + const had = await this.has(key) + await set(key, value, this.store) + return had + } + + async delete(key: string) { + if (!await this.has(key)) { + return false + } + + await del(key, this.store) + return true + } + + async clear() { + const size = await this.size() + await clear(this.store) + return !!size + } + + async size() { + const allKeys = await keys(this.store) + return allKeys.length + } + + get [Symbol.toStringTag]() { + return this.constructor.name + } +} diff --git a/src/stream/Encryption.ts b/src/stream/encryption/Encryption.ts similarity index 92% rename from src/stream/Encryption.ts rename to src/stream/encryption/Encryption.ts index 161c484ec..1ef44fb27 100644 --- a/src/stream/Encryption.ts +++ b/src/stream/encryption/Encryption.ts @@ -7,14 +7,14 @@ import { Crypto } from 'node-webcrypto-ossl' import { arrayify, hexlify } from '@ethersproject/bytes' import { MessageLayer } from 'streamr-client-protocol' -import { uuid } from '../utils' +import { uuid } from '../../utils' const { StreamMessage, EncryptedGroupKey } = MessageLayer -export class UnableToDecryptError extends Error { +export class StreamMessageProcessingError extends Error { streamMessage: MessageLayer.StreamMessage constructor(message = '', streamMessage: MessageLayer.StreamMessage) { - super(`Unable to decrypt. ${message} ${util.inspect(streamMessage)}`) + super(`Could not process. ${message} ${util.inspect(streamMessage)}`) this.streamMessage = streamMessage if (Error.captureStackTrace) { Error.captureStackTrace(this, this.constructor) @@ -22,9 +22,18 @@ export class UnableToDecryptError extends Error { } } +export class UnableToDecryptError extends StreamMessageProcessingError { + constructor(message = '', streamMessage: MessageLayer.StreamMessage) { + super(`Unable to decrypt. ${message} ${util.inspect(streamMessage)}`, streamMessage) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor) + } + } +} + class InvalidGroupKeyError extends Error { groupKey: GroupKey | any - constructor(message: string, groupKey?: GroupKey) { + constructor(message: string, groupKey?: any) { super(message) this.groupKey = groupKey if (Error.captureStackTrace) { @@ -59,6 +68,8 @@ function GroupKeyObjectFromProps(data: GroupKeyProps | GroupKeyObject) { interface GroupKey extends GroupKeyObject {} +export type GroupKeyish = GroupKey | GroupKeyObject | ConstructorParameters + // eslint-disable-next-line no-redeclare class GroupKey { static InvalidGroupKeyError = InvalidGroupKeyError @@ -148,7 +159,7 @@ class GroupKey { return new GroupKey(id, keyBytes) } - static from(maybeGroupKey: GroupKey | GroupKeyObject | ConstructorParameters) { + static from(maybeGroupKey: GroupKeyish) { if (!maybeGroupKey || typeof maybeGroupKey !== 'object') { throw new InvalidGroupKeyError(`Group key must be object ${util.inspect(maybeGroupKey)}`) } @@ -224,10 +235,15 @@ class EncryptionUtilBase { } } - /* + /** * Returns a Buffer or a hex String */ - static encryptWithPublicKey(plaintextBuffer: Uint8Array, publicKey: crypto.KeyLike, outputInHex = false) { + /* eslint-disable no-dupe-class-members */ + static encryptWithPublicKey(plaintextBuffer: Uint8Array, publicKey: crypto.KeyLike, outputInHex: true): string + // These overrides tell ts outputInHex returns string + static encryptWithPublicKey(plaintextBuffer: Uint8Array, publicKey: crypto.KeyLike): string + static encryptWithPublicKey(plaintextBuffer: Uint8Array, publicKey: crypto.KeyLike, outputInHex: false): Buffer + static encryptWithPublicKey(plaintextBuffer: Uint8Array, publicKey: crypto.KeyLike, outputInHex: boolean = false) { this.validatePublicKey(publicKey) const ciphertextBuffer = crypto.publicEncrypt(publicKey, plaintextBuffer) if (outputInHex) { @@ -235,11 +251,12 @@ class EncryptionUtilBase { } return ciphertextBuffer } + /* eslint-disable no-dupe-class-members */ /* * Both 'data' and 'groupKey' must be Buffers. Returns a hex string without the '0x' prefix. */ - static encrypt(data: Uint8Array, groupKey: GroupKey) { + static encrypt(data: Uint8Array, groupKey: GroupKey): string { GroupKey.validate(groupKey) const iv = crypto.randomBytes(16) // always need a fresh IV when using CTR mode const cipher = crypto.createCipheriv('aes-256-ctr', groupKey.data, iv) diff --git a/src/stream/encryption/GroupKeyStore.ts b/src/stream/encryption/GroupKeyStore.ts new file mode 100644 index 000000000..d9f09e1c2 --- /dev/null +++ b/src/stream/encryption/GroupKeyStore.ts @@ -0,0 +1,179 @@ +import { GroupKey } from './Encryption' +import ServerPersistentStore from './ServerPersistentStore' + +export interface PersistentStore { + get(key: K): Promise + set(key: K, value: V): Promise + has(key: K): Promise + delete(key: K): Promise + clear(): Promise + size(): Promise +} + +type GroupKeyId = string + +type GroupKeyStoreOptions = { + clientId: string, + streamId: string, + groupKeys: [GroupKeyId, GroupKey][] +} + +export class GroupKeyPersistence { + store: PersistentStore + constructor({ clientId, streamId }: { clientId: string, streamId: string }) { + this.store = new ServerPersistentStore({ clientId, streamId }) + } + + async has(groupKeyId: string) { + return this.store.has(groupKeyId) + } + + async size() { + return this.store.size() + } + + async get(groupKeyId: string) { + const value = await this.store.get(groupKeyId) + if (!value) { return undefined } + return GroupKey.from([groupKeyId, value]) + } + + async add(groupKey: GroupKey) { + return this.set(groupKey.id, groupKey) + } + + async set(groupKeyId: string, value: GroupKey) { + GroupKey.validate(value) + return this.store.set(groupKeyId, value.hex) + } + + async delete(groupKeyId: string) { + return this.store.delete(groupKeyId) + } + + async clear() { + return this.store.clear() + } + + get [Symbol.toStringTag]() { + return this.constructor.name + } +} + +export default class GroupKeyStore { + store + currentGroupKeyId: GroupKeyId | undefined // current key id if any + nextGroupKeys: GroupKey[] = [] // the keys to use next, disappears if not actually used. Max queue size 2 + + constructor({ clientId, streamId, groupKeys }: GroupKeyStoreOptions) { + this.store = new GroupKeyPersistence({ clientId, streamId }) + + groupKeys.forEach(([groupKeyId, groupKey]) => { + GroupKey.validate(groupKey) + if (groupKeyId !== groupKey.id) { + throw new Error(`Ids must match: groupKey.id: ${groupKey.id}, groupKeyId: ${groupKeyId}`) + } + // use last init key as current + this.currentGroupKeyId = groupKey.id + }) + } + + private async storeKey(groupKey: GroupKey) { + GroupKey.validate(groupKey) + const existingKey = await this.store.get(groupKey.id) + if (existingKey) { + if (!existingKey.equals(groupKey)) { + throw new GroupKey.InvalidGroupKeyError( + `Trying to add groupKey ${groupKey.id} but key exists & is not equivalent to new GroupKey: ${groupKey}.`, + groupKey + ) + } + + await this.store.set(groupKey.id, existingKey) + return existingKey + } + + await this.store.set(groupKey.id, groupKey) + return groupKey + } + + async has(id: GroupKeyId) { + if (this.currentGroupKeyId === id) { return true } + + if (this.nextGroupKeys.some((nextKey) => nextKey.id === id)) { return true } + + return this.store.has(id) + } + + async isEmpty() { + return !this.nextGroupKeys.length && await this.store.size() === 0 + } + + async useGroupKey(): Promise<[GroupKey | undefined, GroupKey | undefined]> { + const nextGroupKey = this.nextGroupKeys.pop() + // First use of group key on this stream, no current key. Make next key current. + if (!this.currentGroupKeyId && nextGroupKey) { + this.currentGroupKeyId = nextGroupKey.id + return [ + await this.get(this.currentGroupKeyId!), + undefined, + ] + } + + // Keep using current key (empty next) + if (this.currentGroupKeyId != null && !nextGroupKey) { + return [ + await this.get(this.currentGroupKeyId), + undefined + ] + } + + // Key changed (non-empty next). return current + next. Make next key current. + if (this.currentGroupKeyId != null && nextGroupKey != null) { + const prevId = this.currentGroupKeyId + this.currentGroupKeyId = nextGroupKey.id + const prevGroupKey = await this.get(prevId) + // use current key one more time + return [ + prevGroupKey, + nextGroupKey, + ] + } + + // Generate & use new key if none already set. + await this.rotateGroupKey() + return this.useGroupKey() + } + + async get(id: GroupKeyId) { + return this.store.get(id) + } + + async clear() { + this.currentGroupKeyId = undefined + this.nextGroupKeys.length = 0 + return this.store.clear() + } + + async rotateGroupKey() { + return this.setNextGroupKey(GroupKey.generate()) + } + + async add(groupKey: GroupKey) { + return this.storeKey(groupKey) + } + + async setNextGroupKey(newKey: GroupKey) { + GroupKey.validate(newKey) + this.nextGroupKeys.unshift(newKey) + this.nextGroupKeys.length = Math.min(this.nextGroupKeys.length, 2) + await this.storeKey(newKey) + } + + async rekey() { + const newKey = GroupKey.generate() + await this.storeKey(newKey) + this.currentGroupKeyId = newKey.id + this.nextGroupKeys.length = 0 + } +} diff --git a/src/stream/encryption/KeyExchangePublisher.ts b/src/stream/encryption/KeyExchangePublisher.ts new file mode 100644 index 000000000..c452ce684 --- /dev/null +++ b/src/stream/encryption/KeyExchangePublisher.ts @@ -0,0 +1,198 @@ +import { + StreamMessage, GroupKeyRequest, GroupKeyResponse, EncryptedGroupKey, GroupKeyErrorResponse, Errors +} from 'streamr-client-protocol' +import pMemoize from 'p-memoize' + +import Scaffold from '../../utils/Scaffold' + +import { validateOptions } from '../utils' +import EncryptionUtil, { GroupKey, StreamMessageProcessingError } from './Encryption' +import type { Subscription } from '../../subscribe' +import { StreamrClient } from '../../StreamrClient' +import GroupKeyStore from './GroupKeyStore' +import { + subscribeToKeyExchangeStream, + parseGroupKeys, + getKeyExchangeStreamId, + GroupKeysSerialized +} from './KeyExchangeUtils' + +const { ValidationError } = Errors + +class InvalidGroupKeyRequestError extends ValidationError { + code: string + constructor(...args: ConstructorParameters) { + super(...args) + this.code = 'INVALID_GROUP_KEY_REQUEST' + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor) + } + } +} + +async function catchKeyExchangeError(client: StreamrClient, streamMessage: StreamMessage, fn: (...args: any[]) => Promise) { + try { + return await fn() + } catch (error) { + const subscriberId = streamMessage.getPublisherId() + const msg = streamMessage.getParsedContent() + const { streamId, requestId, groupKeyIds } = GroupKeyRequest.fromArray(msg) + return client.publish(getKeyExchangeStreamId(subscriberId), new GroupKeyErrorResponse({ + requestId, + streamId, + errorCode: error.code || 'UNEXPECTED_ERROR', + errorMessage: error.message, + groupKeyIds + })) + } +} + +async function PublisherKeyExhangeSubscription(client: StreamrClient, getGroupKeyStore: (streamId: string) => Promise) { + async function onKeyExchangeMessage(_parsedContent: any, streamMessage: StreamMessage) { + return catchKeyExchangeError(client, streamMessage, async () => { + if (streamMessage.messageType !== StreamMessage.MESSAGE_TYPES.GROUP_KEY_REQUEST) { + return Promise.resolve() + } + + // No need to check if parsedContent contains the necessary fields because it was already checked during deserialization + const { requestId, streamId, rsaPublicKey, groupKeyIds } = GroupKeyRequest.fromArray(streamMessage.getParsedContent()) + + const subscriberId = streamMessage.getPublisherId() + + const groupKeyStore = await getGroupKeyStore(streamId) + const isSubscriber = await client.isStreamSubscriber(streamId, subscriberId) + const encryptedGroupKeys = (!isSubscriber ? [] : await Promise.all(groupKeyIds.map(async (id) => { + const groupKey = await groupKeyStore.get(id) + if (!groupKey) { + return null // will be filtered out + } + const key = EncryptionUtil.encryptWithPublicKey(groupKey.data, rsaPublicKey, true) + return new EncryptedGroupKey(id, key) + }))).filter(Boolean) as EncryptedGroupKey[] + + client.debug('Publisher: Subscriber requested groupKeys: %d. Got: %d. %o', groupKeyIds.length, encryptedGroupKeys.length, { + subscriberId, + groupKeyIds, + responseKeys: encryptedGroupKeys.map(({ groupKeyId }) => groupKeyId), + }) + + const response = new GroupKeyResponse({ + streamId, + requestId, + encryptedGroupKeys, + }) + + // hack overriding toStreamMessage method to set correct encryption type + const toStreamMessage = response.toStreamMessage.bind(response) + response.toStreamMessage = (...args) => { + const msg = toStreamMessage(...args) + msg.encryptionType = StreamMessage.ENCRYPTION_TYPES.RSA + return msg + } + + return client.publish(getKeyExchangeStreamId(subscriberId), response) + }) + } + + const sub = await subscribeToKeyExchangeStream(client, onKeyExchangeMessage) + sub.on('error', (err: Error | StreamMessageProcessingError) => { + if (!('streamMessage' in err)) { + return // do nothing + } + + // wrap error and translate into ErrorResponse. + catchKeyExchangeError(client, err.streamMessage, () => { // eslint-disable-line promise/no-promise-in-callback + // rethrow so catchKeyExchangeError handles it + throw new InvalidGroupKeyRequestError(err.message) + }).catch((unexpectedError) => { + sub.emit('error', unexpectedError) + }) + }) + + return sub +} + +type KeyExhangeOptions = { + groupKeys?: Record +} + +export class PublisherKeyExhange { + enabled = true + next + client + initialGroupKeys + constructor(client: StreamrClient, { groupKeys = {} }: KeyExhangeOptions = {}) { + this.client = client + this.initialGroupKeys = groupKeys + this.getGroupKeyStore = pMemoize(this.getGroupKeyStore.bind(this), { + cacheKey([maybeStreamId]) { + const { streamId } = validateOptions(maybeStreamId) + return streamId + } + }) + + let sub: Subscription | undefined + this.next = Scaffold([ + async () => { + sub = await PublisherKeyExhangeSubscription(client, this.getGroupKeyStore) + return async () => { + if (!sub) { return } + const cancelTask = sub.cancel() + sub = undefined + await cancelTask + } + } + ], async () => this.enabled) + } + + async getGroupKeyStore(streamId: string) { + const clientId = await this.client.getAddress() + return new GroupKeyStore({ + clientId, + streamId, + groupKeys: [...parseGroupKeys(this.initialGroupKeys[streamId]).entries()] + }) + } + + async rotateGroupKey(streamId: string) { + if (!this.enabled) { return } + const groupKeyStore = await this.getGroupKeyStore(streamId) + await groupKeyStore.rotateGroupKey() + } + + async setNextGroupKey(streamId: string, groupKey: GroupKey) { + if (!this.enabled) { return } + const groupKeyStore = await this.getGroupKeyStore(streamId) + + await groupKeyStore.setNextGroupKey(groupKey) + } + + async useGroupKey(streamId: string) { + await this.next() + if (!this.enabled) { return [] } + const groupKeyStore = await this.getGroupKeyStore(streamId) + return groupKeyStore.useGroupKey() + } + + async hasAnyGroupKey(streamId: string) { + const groupKeyStore = await this.getGroupKeyStore(streamId) + return !groupKeyStore.isEmpty() + } + + async rekey(streamId: string) { + if (!this.enabled) { return } + const groupKeyStore = await this.getGroupKeyStore(streamId) + await groupKeyStore.rekey() + await this.next() + } + async start() { + this.enabled = true + return this.next() + } + + async stop() { + pMemoize.clear(this.getGroupKeyStore) + this.enabled = false + return this.next() + } +} diff --git a/src/stream/encryption/KeyExchangeSubscriber.ts b/src/stream/encryption/KeyExchangeSubscriber.ts new file mode 100644 index 000000000..209b1ccbd --- /dev/null +++ b/src/stream/encryption/KeyExchangeSubscriber.ts @@ -0,0 +1,212 @@ +import { + StreamMessage, GroupKeyRequest, GroupKeyResponse +} from 'streamr-client-protocol' +import pMemoize from 'p-memoize' +import { uuid, Defer } from '../../utils' + +import { validateOptions } from '../utils' +import EncryptionUtil, { GroupKey } from './Encryption' +import type { Subscription } from '../../subscribe' +import { StreamrClient } from '../../StreamrClient' +import GroupKeyStore from './GroupKeyStore' +import { + GroupKeyId, + subscribeToKeyExchangeStream, + parseGroupKeys, + getKeyExchangeStreamId, + KeyExhangeOptions, +} from './KeyExchangeUtils' + +type MessageMatch = (content: any, streamMessage: StreamMessage) => boolean + +function waitForSubMessage(sub: Subscription, matchFn: MessageMatch) { + const task = Defer() + const onMessage = (content: any, streamMessage: StreamMessage) => { + try { + if (matchFn(content, streamMessage)) { + task.resolve(streamMessage) + } + } catch (err) { + task.reject(err) + } + } + sub.on('message', onMessage) + sub.once('error', task.reject) + task.finally(() => { + sub.off('message', onMessage) + sub.off('error', task.reject) + }).catch(() => {}) // important: prevent unchained finally cleanup causing unhandled rejection + return task +} + +async function getGroupKeysFromStreamMessage(streamMessage: StreamMessage, encryptionUtil: EncryptionUtil) { + const { encryptedGroupKeys } = GroupKeyResponse.fromArray(streamMessage.getParsedContent()) + return Promise.all(encryptedGroupKeys.map(async (encryptedGroupKey) => ( + new GroupKey( + encryptedGroupKey.groupKeyId, + await encryptionUtil.decryptWithPrivateKey(encryptedGroupKey.encryptedGroupKeyHex, true) + ) + ))) +} + +async function SubscriberKeyExhangeSubscription( + client: StreamrClient, + getGroupKeyStore: (streamId: string) => Promise, + encryptionUtil: EncryptionUtil +) { + let sub: Subscription + async function onKeyExchangeMessage(_parsedContent: any, streamMessage: StreamMessage) { + try { + const { messageType } = streamMessage + const { MESSAGE_TYPES } = StreamMessage + if (messageType !== MESSAGE_TYPES.GROUP_KEY_ANNOUNCE) { + return + } + + const groupKeys = await getGroupKeysFromStreamMessage(streamMessage, encryptionUtil) + const groupKeyStore = await getGroupKeyStore(streamMessage.getStreamId()) + await Promise.all(groupKeys.map(async (groupKey) => ( + groupKeyStore.add(groupKey) + ))) + } catch (err) { + sub.emit('error', err) + } + } + + sub = await subscribeToKeyExchangeStream(client, onKeyExchangeMessage) + sub.on('error', () => {}) // errors should not shut down subscription + return sub +} + +export class SubscriberKeyExchange { + requestKeysStep?: () => Promise + client + initialGroupKeys + encryptionUtil + enabled = true + + constructor(client: StreamrClient, { groupKeys = {} }: KeyExhangeOptions = {}) { + this.client = client + this.initialGroupKeys = groupKeys + this.getGroupKeyStore = pMemoize(this.getGroupKeyStore.bind(this), { + cacheKey([maybeStreamId]) { + const { streamId } = validateOptions(maybeStreamId) + return streamId + } + }) + this.encryptionUtil = new EncryptionUtil(client.options.keyExchange) + } + + async getSubscription() { + return SubscriberKeyExhangeSubscription(this.client, this.getGroupKeyStore, this.encryptionUtil) + } + + async requestKeys({ streamId, publisherId, groupKeyIds }: { + streamId: string, + publisherId: string, + groupKeyIds: GroupKeyId[] + }) { + const requestId = uuid('GroupKeyRequest') + const rsaPublicKey = this.encryptionUtil.getPublicKey() + const keyExchangeStreamId = getKeyExchangeStreamId(publisherId) + let sub!: Subscription + try { + sub = await this.getSubscription() + const responseTask = waitForSubMessage(sub, (content, streamMessage) => { + const { messageType } = streamMessage + const matchesMessageType = ( + messageType === StreamMessage.MESSAGE_TYPES.GROUP_KEY_RESPONSE + || messageType === StreamMessage.MESSAGE_TYPES.GROUP_KEY_ERROR_RESPONSE + ) + + if (!matchesMessageType) { + return false + } + + const groupKeyResponse = GroupKeyResponse.fromArray(content) + return groupKeyResponse.requestId === requestId + }) + const msg = new GroupKeyRequest({ + streamId, + requestId, + rsaPublicKey, + groupKeyIds, + }) + await this.client.publish(keyExchangeStreamId, msg) + const response = await responseTask + return response ? getGroupKeysFromStreamMessage(response, this.encryptionUtil) : [] + } finally { + if (sub) { + await sub.unsubscribe() + } + } + } + + async getGroupKeyStore(streamId: string) { + const clientId = await this.client.getAddress() + return new GroupKeyStore({ + clientId, + streamId, + groupKeys: [...parseGroupKeys(this.initialGroupKeys[streamId]).entries()] + }) + } + + async getKey(streamMessage: StreamMessage) { + const streamId = streamMessage.getStreamId() + const publisherId = streamMessage.getPublisherId() + const { groupKeyId } = streamMessage + if (!groupKeyId) { + return Promise.resolve() + } + + const groupKeyStore = await this.getGroupKeyStore(streamId) + + if (!this.enabled) { return Promise.resolve() } + const existingGroupKey = await groupKeyStore.get(groupKeyId) + if (!this.enabled) { return Promise.resolve() } + + if (existingGroupKey) { + return existingGroupKey + } + + const receivedGroupKeys = await this.requestKeys({ + streamId, + publisherId, + groupKeyIds: [groupKeyId], + }) + + await Promise.all(receivedGroupKeys.map(async (groupKey: GroupKey) => ( + groupKeyStore.add(groupKey) + ))) + + return groupKeyStore.get(groupKeyId) + } + + cleanupPending() { + pMemoize.clear(this.getGroupKeyStore) + } + + async getGroupKey(streamMessage: StreamMessage) { + if (!streamMessage.groupKeyId) { return [] } + await this.encryptionUtil.onReady() + + return this.getKey(streamMessage) + } + + async start() { + this.enabled = true + } + + async addNewKey(streamMessage: StreamMessage) { + if (!streamMessage.newGroupKey) { return } + const streamId = streamMessage.getStreamId() + const groupKeyStore = await this.getGroupKeyStore(streamId) + // newGroupKey has been converted into GroupKey + const groupKey: unknown = streamMessage.newGroupKey + await groupKeyStore.add(groupKey as GroupKey) + } + + async stop() { + this.enabled = false + } +} diff --git a/src/stream/encryption/KeyExchangeUtils.ts b/src/stream/encryption/KeyExchangeUtils.ts new file mode 100644 index 000000000..98d392071 --- /dev/null +++ b/src/stream/encryption/KeyExchangeUtils.ts @@ -0,0 +1,74 @@ +import { + StreamMessage, Errors +} from 'streamr-client-protocol' + +import { GroupKey, GroupKeyish } from './Encryption' +import { StreamrClient } from '../../StreamrClient' + +const KEY_EXCHANGE_STREAM_PREFIX = 'SYSTEM/keyexchange' + +export const { ValidationError } = Errors + +export function isKeyExchangeStream(id = '') { + return id.startsWith(KEY_EXCHANGE_STREAM_PREFIX) +} + +/* +class InvalidGroupKeyResponseError extends Error { + constructor(...args) { + super(...args) + this.code = 'INVALID_GROUP_KEY_RESPONSE' + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor) + } + } +} + +class InvalidContentTypeError extends Error { + constructor(...args) { + super(...args) + this.code = 'INVALID_MESSAGE_TYPE' + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor) + } + } +} +*/ + +type Address = string +export type GroupKeyId = string + +export function getKeyExchangeStreamId(address: Address) { + if (isKeyExchangeStream(address)) { + return address // prevent ever double-handling + } + return `${KEY_EXCHANGE_STREAM_PREFIX}/${address.toLowerCase()}` +} + +export type GroupKeysSerialized = Record + +export function parseGroupKeys(groupKeys: GroupKeysSerialized = {}): Map { + return new Map(Object.entries(groupKeys || {}).map(([key, value]) => { + if (!value || !key) { return null } + return [key, GroupKey.from(value)] + }).filter(Boolean) as []) +} + +export async function subscribeToKeyExchangeStream(client: StreamrClient, onKeyExchangeMessage: (msg: any, streamMessage: StreamMessage) => void) { + const { options } = client + if ((!options.auth!.privateKey && !options.auth!.ethereum) || !options.keyExchange) { + return Promise.resolve() + } + + await client.session.getSessionToken() // trigger auth errors if any + // subscribing to own keyexchange stream + const publisherId = await client.getUserId() + const streamId = getKeyExchangeStreamId(publisherId) + const sub = await client.subscribe(streamId, onKeyExchangeMessage) + sub.on('error', () => {}) // errors should not shut down subscription + return sub +} + +export type KeyExhangeOptions = { + groupKeys?: Record +} diff --git a/src/stream/encryption/ServerPersistentStore.ts b/src/stream/encryption/ServerPersistentStore.ts new file mode 100644 index 000000000..e348b20d1 --- /dev/null +++ b/src/stream/encryption/ServerPersistentStore.ts @@ -0,0 +1,97 @@ +import envPaths from 'env-paths' +import { dirname, join } from 'path' +import { promises as fs } from 'fs' +import { open, Database } from 'sqlite' +import sqlite3 from 'sqlite3' + +import { PersistentStore } from './GroupKeyStore' +import { pOnce } from '../../utils' + +export default class ServerPersistentStore implements PersistentStore { + readonly clientId: string + readonly streamId: string + readonly dbFilePath: string + private store?: Database + private error?: Error + + constructor({ clientId, streamId }: { clientId: string, streamId: string }) { + this.streamId = encodeURIComponent(streamId) + this.clientId = encodeURIComponent(clientId) + const paths = envPaths('streamr-client') + const dbFilePath = join(paths.data, clientId, 'GroupKeys.db') + this.dbFilePath = dbFilePath + + this.init = pOnce(this.init.bind(this)) + } + + async init() { + try { + await fs.mkdir(dirname(this.dbFilePath), { recursive: true }) + // open the database + const store = await open({ + filename: this.dbFilePath, + driver: sqlite3.Database + }) + await store.exec(`CREATE TABLE IF NOT EXISTS GroupKeys ( + id TEXT, + groupKey TEXT, + streamId TEXT + )`) + await store.exec('CREATE UNIQUE INDEX IF NOT EXISTS name ON GroupKeys (id)') + this.store = store + } catch (err) { + if (!this.error) { + this.error = err + } + } + + if (this.error) { + throw this.error + } + } + + async get(key: string) { + await this.init() + const value = await this.store!.get('SELECT groupKey FROM GroupKeys WHERE id = ? AND streamId = ?', key, this.streamId) + return value?.groupKey + } + + async has(key: string) { + await this.init() + const value = await this.store!.get('SELECT COUNT(*) FROM GroupKeys WHERE id = ? AND streamId = ?', key, this.streamId) + return value && value['COUNT(*)'] != null && value['COUNT(*)'] !== 0 + } + + async set(key: string, value: string) { + await this.init() + const result = await this.store!.run('INSERT INTO GroupKeys VALUES ($id, $groupKey, $streamId) ON CONFLICT DO NOTHING', { + $id: key, + $groupKey: value, + $streamId: this.streamId, + }) + + return !!result?.changes + } + + async delete(key: string) { + await this.init() + const result = await this.store!.run('DELETE FROM GroupKeys WHERE id = ? AND streamId = ?', key, this.streamId) + return !!result?.changes + } + + async clear() { + await this.init() + const result = await this.store!.run('DELETE FROM GroupKeys WHERE streamId = ?', this.streamId) + return !!result?.changes + } + + async size() { + await this.init() + const size = await this.store!.get('SELECT COUNT(*) FROM GroupKeys WHERE streamId = ?;', this.streamId) + return size && size['COUNT(*)'] + } + + get [Symbol.toStringTag]() { + return this.constructor.name + } +} diff --git a/src/stream/index.ts b/src/stream/index.ts index 397b5dda2..d48507947 100644 --- a/src/stream/index.ts +++ b/src/stream/index.ts @@ -3,6 +3,8 @@ import { getAddress } from '@ethersproject/address' import { getEndpointUrl, until } from '../utils' import authFetch from '../rest/authFetch' +export { GroupKey } from './encryption/Encryption' + import { StorageNode } from './StorageNode' import { StreamrClient } from '../StreamrClient' import { EthereumAddress } from '../types' diff --git a/src/subscribe/Decrypt.js b/src/subscribe/Decrypt.js index 6e97f9f76..6c77d653d 100644 --- a/src/subscribe/Decrypt.js +++ b/src/subscribe/Decrypt.js @@ -1,7 +1,7 @@ import { MessageLayer } from 'streamr-client-protocol' -import EncryptionUtil, { UnableToDecryptError } from '../stream/Encryption' -import { SubscriberKeyExchange } from '../stream/KeyExchange' +import EncryptionUtil, { UnableToDecryptError } from '../stream/encryption/Encryption' +import { SubscriberKeyExchange } from '../stream/encryption/KeyExchangeSubscriber' const { StreamMessage } = MessageLayer @@ -10,14 +10,14 @@ export default function Decrypt(client, options = {}) { // noop unless message encrypted return (streamMessage) => { if (streamMessage.groupKeyId) { - throw new Error('No keyExchange configured, cannot decrypt message.') + throw new UnableToDecryptError('No keyExchange configured, cannot decrypt any message.', streamMessage) } return streamMessage } } - const requestKey = SubscriberKeyExchange(client, { + const keyExchange = new SubscriberKeyExchange(client, { ...options, groupKeys: { ...client.options.groupKeys, @@ -38,16 +38,22 @@ export default function Decrypt(client, options = {}) { } try { - const groupKey = await requestKey(streamMessage).catch((err) => { + const groupKey = await keyExchange.getGroupKey(streamMessage).catch((err) => { throw new UnableToDecryptError(`Could not get GroupKey: ${streamMessage.groupKeyId} – ${err.message}`, streamMessage) }) if (!groupKey) { - throw new UnableToDecryptError(`Group key not found: ${streamMessage.groupKeyId}`, streamMessage) + throw new UnableToDecryptError([ + `Could not get GroupKey: ${streamMessage.groupKeyId}`, + 'Publisher is offline, key does not exist or no permission to access key.', + ].join(' '), streamMessage) } + await EncryptionUtil.decryptStreamMessage(streamMessage, groupKey) - requestKey.addNewKey(streamMessage) + await keyExchange.addNewKey(streamMessage) } catch (err) { + // clear cached permissions if cannot decrypt, likely permissions need updating + client.cached.clearStream(streamMessage.getStreamId()) await onError(err, streamMessage) } finally { yield streamMessage @@ -56,8 +62,8 @@ export default function Decrypt(client, options = {}) { } return Object.assign(decrypt, { - stop() { - return requestKey.stop() + async stop() { + return keyExchange.stop() } }) } diff --git a/src/subscribe/index.ts b/src/subscribe/index.ts index 800fea9fb..2e7fa5c5b 100644 --- a/src/subscribe/index.ts +++ b/src/subscribe/index.ts @@ -49,7 +49,7 @@ export class Subscription extends Emitter { /** @internal */ debug - constructor(client: StreamrClient, opts: Todo, onFinally = defaultOnFinally) { + constructor(client: StreamrClient, opts: Todo, onFinally: MaybeAsync<(err?: any) => void> = defaultOnFinally) { super() this.client = client this.options = validateOptions(opts) @@ -601,7 +601,7 @@ export class Subscriber { return this.subscriptions.count(options) } - async subscribe(opts: StreamPartDefinition, onFinally?: MaybeAsync<(err?: any) => void>) { + async subscribe(opts: StreamPartDefinition, onFinally?: Todo) { return this.subscriptions.add(opts, onFinally) } diff --git a/src/subscribe/pipeline.js b/src/subscribe/pipeline.js index 745d2e02f..71744adc6 100644 --- a/src/subscribe/pipeline.js +++ b/src/subscribe/pipeline.js @@ -129,6 +129,7 @@ export default function MessagePipeline(client, opts = {}, onFinally = async (er // custom pipeline steps ...afterSteps ], async (err, ...args) => { + decrypt.stop() await msgStream.cancel(err) try { if (err) { diff --git a/src/utils/index.ts b/src/utils/index.ts index caf748c8b..8690dfa3e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -243,7 +243,7 @@ export function Defer(executor: (...args: Parameters['then']>) => } // eslint-disable-next-line promise/param-names - const p = new Promise((_resolve, _reject) => { + const p: Promise = new Promise((_resolve, _reject) => { resolveFn = _resolve rejectFn = _reject executor(resolve, reject) @@ -389,6 +389,46 @@ export function pOne(fn: F.Function) { } } +type Unwrap = T extends Promise ? U : T + +/** + * Only allows calling `fn` once. + * Returns same promise while task is executing. + */ + +export function pOnce( + fn: (...args: Args) => R +): (...args: Args) => Promise> { + let inProgress: Promise | undefined + let started = false + let value: Unwrap + let error: Error | undefined + return async (...args: Args) => { + if (!started) { + started = true + inProgress = (async () => { + try { + value = await Promise.resolve(fn(...args)) as Unwrap + } catch (err) { + error = err + } finally { + inProgress = undefined + } + })() + } + + if (inProgress) { + await inProgress + } + + if (error) { + throw error + } + + return value + } +} + export class TimeoutError extends Error { timeout: number constructor(msg = '', timeout = 0) { diff --git a/src/utils/iterators.js b/src/utils/iterators.js index 603a6216a..179349caa 100644 --- a/src/utils/iterators.js +++ b/src/utils/iterators.js @@ -374,6 +374,7 @@ export function pipeline(iterables = [], onFinally = defaultOnFinally, { end, .. prev.id = prev.id || 'inter-' + nextIterable.id nextIterable.from(prev, { end }) } + yield* nextIterable }()), async (err) => { if (!error && err && error !== err) { diff --git a/test/integration/Encryption.test.js b/test/integration/Encryption.test.js deleted file mode 100644 index 8822a9925..000000000 --- a/test/integration/Encryption.test.js +++ /dev/null @@ -1,853 +0,0 @@ -import { wait } from 'streamr-test-utils' -import { MessageLayer } from 'streamr-client-protocol' - -import { describeRepeats, fakePrivateKey, uid, Msg, getPublishTestMessages } from '../utils' -import { Defer } from '../../src/utils' -import { StreamrClient } from '../../src/StreamrClient' -import { GroupKey } from '../../src/stream/Encryption' -import Connection from '../../src/Connection' -import { StorageNode } from '../../src/stream/StorageNode' - -import config from './config' - -const TIMEOUT = 10 * 1000 - -const { StreamMessage } = MessageLayer - -describeRepeats('decryption', () => { - let publishTestMessages - let expectErrors = 0 // check no errors by default - let errors = [] - - const getOnError = (errs) => jest.fn((err) => { - errs.push(err) - }) - - let onError = jest.fn() - let client - let stream - - const createClient = (opts = {}) => { - const c = new StreamrClient({ - ...config.clientOptions, - auth: { - privateKey: fakePrivateKey(), - }, - autoConnect: false, - autoDisconnect: false, - disconnectDelay: 1, - publishAutoDisconnectDelay: 50, - maxRetries: 2, - ...opts, - }) - c.onError = jest.fn() - c.on('error', onError) - - return c - } - - function checkEncryptionMessages(testClient) { - const onSendTest = Defer() - testClient.connection.on('_send', onSendTest.wrapError((sendingMsg) => { - // check encryption is as expected - const { streamMessage } = sendingMsg - if (!streamMessage) { - return - } - - if (streamMessage.messageType === StreamMessage.MESSAGE_TYPES.MESSAGE) { - expect(streamMessage.encryptionType).toEqual(StreamMessage.ENCRYPTION_TYPES.AES) - } else if (streamMessage.messageType === StreamMessage.MESSAGE_TYPES.GROUP_KEY_RESPONSE) { - expect(streamMessage.encryptionType).toEqual(StreamMessage.ENCRYPTION_TYPES.RSA) - } else { - expect(streamMessage.encryptionType).toEqual(StreamMessage.ENCRYPTION_TYPES.NONE) - } - })) - return onSendTest - } - - beforeEach(() => { - errors = [] - expectErrors = 0 - onError = getOnError(errors) - }) - - afterEach(async () => { - await wait() - // ensure no unexpected errors - expect(errors).toHaveLength(expectErrors) - if (client) { - expect(client.onError).toHaveBeenCalledTimes(expectErrors) - } - }) - - afterEach(async () => { - await wait() - if (client) { - client.debug('disconnecting after test') - await client.disconnect() - } - - const openSockets = Connection.getOpen() - if (openSockets !== 0) { - await Connection.closeOpen() - throw new Error(`sockets not closed: ${openSockets}`) - } - }) - - async function setupClient(opts) { - client = createClient(opts) - await Promise.all([ - client.session.getSessionToken(), - client.connect(), - ]) - - const name = uid('stream') - stream = await client.createStream({ - name, - requireEncryptedData: true, - }) - - await stream.addToStorageNode(StorageNode.STREAMR_DOCKER_DEV) - - publishTestMessages = getPublishTestMessages(client, { - stream - }) - } - - beforeEach(async () => { - await setupClient() - }) - - it('client.subscribe can decrypt encrypted messages if it knows the group key', async () => { - const groupKey = GroupKey.generate() - const keys = { - [stream.id]: { - [groupKey.id]: groupKey, - } - } - const msg = Msg() - const done = Defer() - const sub = await client.subscribe({ - stream: stream.id, - groupKeys: keys, - }, done.wrap((parsedContent, streamMessage) => { - expect(parsedContent).toEqual(msg) - // Check signature stuff - expect(streamMessage.signatureType).toBe(StreamMessage.SIGNATURE_TYPES.ETH) - expect(streamMessage.getPublisherId()) - expect(streamMessage.signature) - })) - - client.once('error', done.reject) - client.setNextGroupKey(stream.id, groupKey) - // Publish after subscribed - await Promise.all([ - client.publish(stream.id, msg), - done, - ]) - // All good, unsubscribe - await client.unsubscribe(sub) - }) - - it('client.subscribe can get the group key and decrypt encrypted message using an RSA key pair', async () => { - const done = Defer() - const msg = Msg() - const groupKey = GroupKey.generate() - // subscribe without knowing the group key to decrypt stream messages - const sub = await client.subscribe({ - stream: stream.id, - }, done.wrap((parsedContent, streamMessage) => { - expect(parsedContent).toEqual(msg) - - // Check signature stuff - expect(streamMessage.signatureType).toBe(StreamMessage.SIGNATURE_TYPES.ETH) - expect(streamMessage.getPublisherId()) - expect(streamMessage.signature) - })) - sub.once('error', done.reject) - - await client.setNextGroupKey(stream.id, groupKey) - const onEncryptionMessageErr = checkEncryptionMessages(client) - - await Promise.all([ - client.publish(stream.id, msg), - done, - ]) - onEncryptionMessageErr.resolve() // will be ignored if errored - await onEncryptionMessageErr - // All good, unsubscribe - await client.unsubscribe(sub) - }, TIMEOUT) - - it('changing group key injects group key into next stream message', async () => { - const done = Defer() - const msgs = [Msg(), Msg(), Msg()] - const received = [] - // subscribe without knowing the group key to decrypt stream messages - const sub = await client.subscribe({ - stream: stream.id, - }, done.wrapError((_parsedContent, streamMessage) => { - // Check signature stuff - received.push(streamMessage) - if (received.length === msgs.length) { - done.resolve() - } - })) - - sub.once('error', done.reject) - - const onEncryptionMessageErr = checkEncryptionMessages(client) - // id | groupKeyId | newGroupKey (encrypted by groupKeyId) - // msg1 gk2 - - // msg2 gk2 gk3 - // msg3 gk3 - - const groupKey1 = GroupKey.generate() - const groupKey2 = GroupKey.generate() - await client.setNextGroupKey(stream.id, groupKey1) - await client.publish(stream.id, msgs[0]) - await client.setNextGroupKey(stream.id, groupKey2) - await client.publish(stream.id, msgs[1]) - await client.publish(stream.id, msgs[2]) - await done - expect(received.map((m) => m.getParsedContent())).toEqual(msgs) - received.forEach((streamMessage, index) => { - expect(streamMessage.signatureType).toBe(StreamMessage.SIGNATURE_TYPES.ETH) - expect(streamMessage.getPublisherId()) - expect(streamMessage.signature) - switch (index) { - case 0: { - expect(streamMessage.newGroupKey).toEqual(null) - expect(streamMessage.groupKeyId).toEqual(groupKey1.id) - break - } - case 1: { - expect(streamMessage.newGroupKey).toEqual(groupKey2) - expect(streamMessage.groupKeyId).toEqual(groupKey1.id) - break - } - case 2: { - expect(streamMessage.newGroupKey).toEqual(null) - expect(streamMessage.groupKeyId).toEqual(groupKey2.id) - break - } - default: { - throw new Error(`should not get here: ${index}`) - } - - } - }) - - onEncryptionMessageErr.resolve() // will be ignored if errored - await onEncryptionMessageErr - // All good, unsubscribe - await client.unsubscribe(sub) - }, TIMEOUT) - - it('errors if rotating group key for no stream', async () => { - expect(async () => ( - client.rotateGroupKey() - )).rejects.toThrow('streamId') - }) - - it('errors if setting group key for no stream', async () => { - expect(async () => ( - client.setNextGroupKey(undefined, GroupKey.generate()) - )).rejects.toThrow('streamId') - }) - - it('allows other users to get group key', async () => { - let otherClient - let sub - try { - otherClient = createClient({ - autoConnect: true, - autoDisconnect: true, - }) - - const onEncryptionMessageErr = checkEncryptionMessages(client) - const onEncryptionMessageErr2 = checkEncryptionMessages(otherClient) - const otherUser = await otherClient.getUserInfo() - await stream.grantPermission('stream_get', otherUser.username) - await stream.grantPermission('stream_subscribe', otherUser.username) - - const done = Defer() - const msg = Msg() - // subscribe without knowing the group key to decrypt stream messages - sub = await otherClient.subscribe({ - stream: stream.id, - }, done.wrap((parsedContent, streamMessage) => { - expect(parsedContent).toEqual(msg) - - // Check signature stuff - expect(streamMessage.signatureType).toBe(StreamMessage.SIGNATURE_TYPES.ETH) - expect(streamMessage.getPublisherId()) - expect(streamMessage.signature) - })) - sub.once('error', done.reject) - - const groupKey = GroupKey.generate() - await client.setNextGroupKey(stream.id, groupKey) - - await Promise.all([ - client.publish(stream.id, msg), - done, - ]) - onEncryptionMessageErr.resolve() // will be ignored if errored - await onEncryptionMessageErr - onEncryptionMessageErr2.resolve() // will be ignored if errored - await onEncryptionMessageErr2 - } finally { - if (otherClient) { - if (sub) { - await otherClient.unsubscribe(sub) - } - await otherClient.disconnect() - await otherClient.logout() - } - } - }, TIMEOUT) - - it('does not encrypt messages in stream without groupkey', async () => { - const name = uid('stream') - const stream2 = await client.createStream({ - name, - requireEncryptedData: false, - }) - - let didFindStream2 = false - - function checkEncryptionMessagesPerStream(testClient) { - const onSendTest = Defer() - testClient.connection.on('_send', onSendTest.wrapError((sendingMsg) => { - // check encryption is as expected - const { streamMessage } = sendingMsg - if (!streamMessage) { - return - } - - if (streamMessage.getStreamId() === stream2.id) { - didFindStream2 = true - // stream2 always unencrypted - expect(streamMessage.encryptionType).toEqual(StreamMessage.ENCRYPTION_TYPES.NONE) - return - } - - if (streamMessage.messageType === StreamMessage.MESSAGE_TYPES.MESSAGE) { - expect(streamMessage.encryptionType).toEqual(StreamMessage.ENCRYPTION_TYPES.AES) - } else if (streamMessage.messageType === StreamMessage.MESSAGE_TYPES.GROUP_KEY_RESPONSE) { - expect(streamMessage.encryptionType).toEqual(StreamMessage.ENCRYPTION_TYPES.RSA) - } else { - expect(streamMessage.encryptionType).toEqual(StreamMessage.ENCRYPTION_TYPES.NONE) - } - })) - return onSendTest - } - - async function testSub(testStream) { - const NUM_MESSAGES = 5 - const done = Defer() - const received = [] - const sub = await client.subscribe({ - stream: testStream.id, - }, done.wrapError((parsedContent) => { - received.push(parsedContent) - if (received.length === NUM_MESSAGES) { - done.resolve() - } - })) - sub.once('error', done.reject) - - const published = await publishTestMessages(NUM_MESSAGES, { - stream: testStream, - }) - - await done - - expect(received).toEqual(published) - } - - const onEncryptionMessageErr = checkEncryptionMessagesPerStream(client) - - const groupKey = GroupKey.generate() - await client.setNextGroupKey(stream.id, groupKey) - - await Promise.all([ - testSub(stream), - testSub(stream2), - ]) - onEncryptionMessageErr.resolve() // will be ignored if errored - await onEncryptionMessageErr - expect(didFindStream2).toBeTruthy() - }, TIMEOUT) - - it('sets group key per-stream', async () => { - const name = uid('stream') - const stream2 = await client.createStream({ - name, - requireEncryptedData: true, - }) - - const groupKey = GroupKey.generate() - await client.setNextGroupKey(stream.id, groupKey) - const groupKey2 = GroupKey.generate() - await client.setNextGroupKey(stream2.id, groupKey2) - - function checkEncryptionMessagesPerStream(testClient) { - const onSendTest = Defer() - testClient.connection.on('_send', onSendTest.wrapError((sendingMsg) => { - // check encryption is as expected - const { streamMessage } = sendingMsg - if (!streamMessage) { - return - } - - if (streamMessage.getStreamId() === stream2.id) { - expect(streamMessage.groupKeyId).toEqual(groupKey2.id) - } - - if (streamMessage.getStreamId() === stream.id) { - expect(streamMessage.groupKeyId).toEqual(groupKey.id) - } - - if (streamMessage.messageType === StreamMessage.MESSAGE_TYPES.MESSAGE) { - expect(streamMessage.encryptionType).toEqual(StreamMessage.ENCRYPTION_TYPES.AES) - } else if (streamMessage.messageType === StreamMessage.MESSAGE_TYPES.GROUP_KEY_RESPONSE) { - expect(streamMessage.encryptionType).toEqual(StreamMessage.ENCRYPTION_TYPES.RSA) - } else { - expect(streamMessage.encryptionType).toEqual(StreamMessage.ENCRYPTION_TYPES.NONE) - } - })) - return onSendTest - } - - async function testSub(testStream) { - const NUM_MESSAGES = 5 - const done = Defer() - const received = [] - const sub = await client.subscribe({ - stream: testStream.id, - }, done.wrapError((parsedContent) => { - received.push(parsedContent) - if (received.length === NUM_MESSAGES) { - done.resolve() - } - })) - sub.once('error', done.reject) - - const published = await publishTestMessages(NUM_MESSAGES, { - stream: testStream, - }) - - await done - - expect(received).toEqual(published) - } - - const onEncryptionMessageErr = checkEncryptionMessagesPerStream(client) - - await Promise.all([ - testSub(stream), - testSub(stream2), - ]) - onEncryptionMessageErr.resolve() // will be ignored if errored - await onEncryptionMessageErr - }, TIMEOUT) - - it('client.subscribe can get the group key and decrypt multiple encrypted messages using an RSA key pair', async () => { - // subscribe without knowing the group key to decrypt stream messages - const sub = await client.subscribe({ - stream: stream.id, - }) - - const groupKey = GroupKey.generate() - await client.setNextGroupKey(stream.id, groupKey) - const published = await publishTestMessages() - - const received = [] - for await (const msg of sub) { - received.push(msg.getParsedContent()) - if (received.length === published.length) { - return - } - } - - expect(received).toEqual(published) - - // All good, unsubscribe - await client.unsubscribe(sub) - }, TIMEOUT) - - it('subscribe with changing group key', async () => { - // subscribe without knowing the group key to decrypt stream messages - const sub = await client.subscribe({ - stream: stream.id, - }) - - const published = await publishTestMessages(5, { - async beforeEach() { - await client.rotateGroupKey(stream.id) - } - }) - - const received = [] - for await (const msg of sub) { - received.push(msg.getParsedContent()) - if (received.length === published.length) { - return - } - } - - expect(received).toEqual(published) - - // All good, unsubscribe - await client.unsubscribe(sub) - }, TIMEOUT) - - it('client.resend last can get the historical keys for previous encrypted messages', async () => { - // Publish encrypted messages with different keys - const published = await publishTestMessages(5, { - waitForLast: true, - async beforeEach() { - await client.rotateGroupKey(stream.id) - } - }) - - // resend without knowing the historical keys - const sub = await client.resend({ - stream: stream.id, - resend: { - last: 2, - }, - }) - - const received = await sub.collect() - - expect(received).toEqual(published.slice(-2)) - await client.unsubscribe(sub) - }, TIMEOUT) - - it('client.subscribe with resend last can get the historical keys for previous encrypted messages', async () => { - // Publish encrypted messages with different keys - const published = await publishTestMessages(5, { - waitForLast: true, - async beforeEach() { - await client.rotateGroupKey(stream.id) - } - }) - - // subscribe with resend without knowing the historical keys - const sub = await client.subscribe({ - stream: stream.id, - resend: { - last: 2, - }, - }) - - const received = [] - for await (const msg of sub) { - received.push(msg.getParsedContent()) - if (received.length === 2) { - break - } - } - - expect(received).toEqual(published.slice(-2)) - await client.unsubscribe(sub) - }, TIMEOUT) - - it('fails gracefully if cannot decrypt', async () => { - const MAX_MESSAGES = 10 - const groupKey = GroupKey.generate() - const keys = { - [stream.id]: { - [groupKey.id]: groupKey, - } - } - - await client.setNextGroupKey(stream.id, groupKey) - - const BAD_INDEX = 6 - let count = 0 - const { parse } = client.connection - client.connection.parse = (...args) => { - const msg = parse.call(client.connection, ...args) - if (!msg.streamMessage) { - return msg - } - - if (count === BAD_INDEX) { - msg.streamMessage.groupKeyId = 'badgroupkey' - } - - count += 1 - return msg - } - - const sub = await client.subscribe({ - stream: stream.id, - groupKeys: keys, - }) - - const onSubError = jest.fn((err) => { - expect(err).toBeInstanceOf(Error) - expect(err.message).toMatch('decrypt') - }) - - sub.on('error', onSubError) - - // Publish after subscribed - const published = await publishTestMessages(MAX_MESSAGES, { - timestamp: 1111111, - }) - - const received = [] - for await (const m of sub) { - received.push(m.getParsedContent()) - if (received.length === published.length - 1) { - break - } - } - - expect(received).toEqual([ - ...published.slice(0, BAD_INDEX), - ...published.slice(BAD_INDEX + 1, MAX_MESSAGES) - ]) - - expect(onSubError).toHaveBeenCalledTimes(1) - }) - - describe('revoking permissions', () => { - let client2 - - beforeEach(async () => { - client2 = createClient({ id: 'subscriber' }) - await client2.connect() - await client2.session.getSessionToken() - client2.debug('new SUBSCRIBER') - }) - - afterEach(async () => { - await client2.disconnect() - }) - - it('fails gracefully if permission revoked with low cache maxAge', async () => { - await client.disconnect() - await setupClient({ - id: 'publisher', - cache: { - maxAge: 1, - } - }) - - const MAX_MESSAGES = 6 - await client.rotateGroupKey(stream.id) - - const p1 = await stream.grantPermission('stream_get', client2.getPublisherId()) - const p2 = await stream.grantPermission('stream_subscribe', client2.getPublisherId()) - - const sub = await client2.subscribe({ - stream: stream.id, - }) - - const errs = [] - const onSubError = jest.fn((err) => { - errs.push(err) - throw err - }) - - sub.on('error', onSubError) - - const received = [] - // Publish after subscribed - let count = 0 - const REVOKE_AFTER = 3 - const gotMessages = Defer() - // do publish in background otherwise permission is revoked before subscriber starts processing - const publishTask = publishTestMessages(MAX_MESSAGES, { - timestamp: 1111111, - async afterEach() { - count += 1 - if (count === REVOKE_AFTER) { - await gotMessages - await stream.revokePermission(p1.id) - await stream.revokePermission(p2.id) - await client.rekey(stream.id) - } - } - }) - let t - await expect(async () => { - for await (const m of sub) { - received.push(m.getParsedContent()) - if (received.length === REVOKE_AFTER) { - gotMessages.resolve() - clearTimeout(t) - t = setTimeout(() => { - sub.cancel() - }, 10000) - } - - if (received.length === MAX_MESSAGES) { - clearTimeout(t) - break - } - } - }).rejects.toThrow('not a subscriber') - clearTimeout(t) - const published = await publishTask - - expect(received).toEqual([ - ...published.slice(0, REVOKE_AFTER), - ]) - - expect(onSubError).toHaveBeenCalledTimes(1) - }) - - it('fails gracefully if permission revoked with low cache maxAge fail first message', async () => { - await client.disconnect() - await setupClient({ - id: 'publisher', - cache: { - maxAge: 1, - } - }) - const MAX_MESSAGES = 3 - await client.rotateGroupKey(stream.id) - - const p1 = await stream.grantPermission('stream_get', client2.getPublisherId()) - const p2 = await stream.grantPermission('stream_subscribe', client2.getPublisherId()) - - const sub = await client2.subscribe({ - stream: stream.id, - }) - sub.debug('sub', sub.id) - - const errs = [] - const onSubError = jest.fn((err) => { - errs.push(err) - throw err - }) - - sub.on('error', onSubError) - - const received = [] - // Publish after subscribed - let count = 0 - const REVOKE_AFTER = 1 - const gotMessages = Defer() - // do publish in background otherwise permission is revoked before subscriber starts processing - const publishTask = publishTestMessages(MAX_MESSAGES, { - timestamp: 1111111, - async afterEach() { - count += 1 - if (count === REVOKE_AFTER) { - await gotMessages - await stream.revokePermission(p1.id) - await stream.revokePermission(p2.id) - await client.rekey(stream.id) - } - } - }) - let t - await expect(async () => { - for await (const m of sub) { - received.push(m.getParsedContent()) - if (received.length === REVOKE_AFTER) { - gotMessages.resolve() - clearTimeout(t) - t = setTimeout(() => { - sub.cancel() - }, 10000) - } - - if (received.length === MAX_MESSAGES) { - clearTimeout(t) - break - } - } - }).rejects.toThrow('not a subscriber') - clearTimeout(t) - const published = await publishTask - - expect(received).toEqual([ - ...published.slice(0, REVOKE_AFTER), - ]) - - expect(onSubError).toHaveBeenCalledTimes(1) - }) - - it('fails gracefully if permission revoked with high cache maxAge', async () => { - await client.disconnect() - await setupClient({ - cache: { - maxAge: 99999999, - } - }) - - const MAX_MESSAGES = 10 - await client.rotateGroupKey(stream.id) - - const p1 = await stream.grantPermission('stream_get', client2.getPublisherId()) - const p2 = await stream.grantPermission('stream_subscribe', client2.getPublisherId()) - - const sub = await client2.subscribe({ - stream: stream.id, - }) - - const errs = [] - const onSubError = jest.fn((err) => { - errs.push(err) - throw err - }) - - sub.on('error', onSubError) - - const received = [] - // Publish after subscribed - let count = 0 - const REVOKE_AFTER = 6 - const gotMessages = Defer() - // do publish in background otherwise permission is revoked before subscriber starts processing - const publishTask = publishTestMessages(MAX_MESSAGES, { - timestamp: 1111111, - async afterEach() { - count += 1 - if (count === REVOKE_AFTER) { - await gotMessages - await stream.revokePermission(p1.id) - await stream.revokePermission(p2.id) - await client.rekey(stream.id) - } - } - }) - - let t - await expect(async () => { - for await (const m of sub) { - received.push(m.getParsedContent()) - if (received.length === REVOKE_AFTER) { - gotMessages.resolve() - clearTimeout(t) - t = setTimeout(() => { - sub.cancel() - }, 2000) - } - - if (received.length === MAX_MESSAGES) { - clearTimeout(t) - break - } - } - }).rejects.toThrow('decrypt') - clearTimeout(t) - const published = await publishTask - - expect(received).toEqual([ - ...published.slice(0, REVOKE_AFTER), - ]) - - expect(onSubError).toHaveBeenCalledTimes(1) - }) - }) -}) diff --git a/test/integration/Encryption.test.ts b/test/integration/Encryption.test.ts new file mode 100644 index 000000000..9a147b255 --- /dev/null +++ b/test/integration/Encryption.test.ts @@ -0,0 +1,786 @@ +import { wait } from 'streamr-test-utils' +import { MessageLayer } from 'streamr-client-protocol' + +import { describeRepeats, fakePrivateKey, uid, Msg, getPublishTestMessages } from '../utils' +import { Defer } from '../../src/utils' +import { StreamrClient } from '../../src/StreamrClient' +import { GroupKey } from '../../src/stream/encryption/Encryption' +import { Stream, StreamOperation } from '../../src/stream' +import Connection from '../../src/Connection' +import { StorageNode } from '../../src/stream/StorageNode' +import Debug from 'debug' + +import config from './config' + +const debug = Debug('StreamrClient::test') +const TIMEOUT = 10 * 1000 + +const { StreamMessage } = MessageLayer + +describeRepeats('decryption', () => { + let publishTestMessages: ReturnType + let expectErrors = 0 // check no errors by default + let errors: Error[] = [] + + const getOnError = (errs: Error[]) => jest.fn((err) => { + errs.push(err) + }) + + let onError = jest.fn() + let publisher: StreamrClient + let subscriber: StreamrClient + let stream: Stream + + const createClient = (opts = {}) => { + const c = new StreamrClient({ + ...config.clientOptions, + auth: { + privateKey: fakePrivateKey(), + }, + autoConnect: false, + autoDisconnect: false, + // @ts-expect-error + disconnectDelay: 1, + publishAutoDisconnectDelay: 50, + maxRetries: 2, + ...opts, + }) + c.onError = jest.fn() + c.on('error', onError) + + return c + } + + function checkEncryptionMessages(testClient: StreamrClient) { + const onSendTest = Defer() + testClient.connection.on('_send', onSendTest.wrapError((sendingMsg) => { + // check encryption is as expected + const { streamMessage } = sendingMsg + if (!streamMessage) { + return + } + + if (streamMessage.messageType === StreamMessage.MESSAGE_TYPES.MESSAGE) { + expect(streamMessage.encryptionType).toEqual(StreamMessage.ENCRYPTION_TYPES.AES) + } else if (streamMessage.messageType === StreamMessage.MESSAGE_TYPES.GROUP_KEY_RESPONSE) { + expect(streamMessage.encryptionType).toEqual(StreamMessage.ENCRYPTION_TYPES.RSA) + } else { + expect(streamMessage.encryptionType).toEqual(StreamMessage.ENCRYPTION_TYPES.NONE) + } + })) + return onSendTest + } + + beforeEach(() => { + errors = [] + expectErrors = 0 + onError = getOnError(errors) + }) + + afterEach(async () => { + await wait(0) + // ensure no unexpected errors + expect(errors).toHaveLength(expectErrors) + if (publisher) { + expect(publisher.onError).toHaveBeenCalledTimes(expectErrors) + } + }) + + async function cleanupClient(client?: StreamrClient, msg = 'disconnecting after test') { + await wait(0) + if (client) { + client.debug(msg) + await client.disconnect() + } + } + + afterEach(async () => { + await Promise.all([ + cleanupClient(publisher), + cleanupClient(subscriber), + ]) + + const openSockets = Connection.getOpen() + if (openSockets !== 0) { + await Connection.closeOpen() + throw new Error(`sockets not closed: ${openSockets}`) + } + }) + + async function setupClient(opts?: any) { + const client = createClient(opts) + await Promise.all([ + client.session.getSessionToken(), + client.connect(), + ]) + return client + } + + async function setupStream() { + const name = uid('stream') + stream = await publisher.createStream({ + name, + requireEncryptedData: true, + }) + + await stream.addToStorageNode(StorageNode.STREAMR_DOCKER_DEV) + + publishTestMessages = getPublishTestMessages(publisher, { + stream + }) + } + + async function setupPublisherSubscriberClients(opts?: any) { + debug('set up clients', opts) + // eslint-disable-next-line require-atomic-updates, semi-style + ;[publisher, subscriber] = await Promise.all([ + setupClient({ id: uid('publisher'), ...opts }), + setupClient({ id: uid('subscriber'), ...opts }), + ]) + } + + async function grantSubscriberPermissions({ stream: s = stream, client: c = subscriber }: { stream?: Stream, client?: StreamrClient } = {}) { + const p1 = await s.grantPermission(StreamOperation.STREAM_GET, await c.getPublisherId()) + const p2 = await s.grantPermission(StreamOperation.STREAM_SUBSCRIBE, await c.getPublisherId()) + return [p1, p2] + } + + describe('using default config', () => { + beforeEach(async () => { + await setupPublisherSubscriberClients() + }) + beforeEach(async () => { + await setupStream() + }) + + it('client.subscribe can decrypt encrypted messages if it knows the group key', async () => { + const groupKey = GroupKey.generate() + const keys = { + [stream.id]: { + [groupKey.id]: groupKey, + } + } + const msg = Msg() + const done = Defer() + await grantSubscriberPermissions() + const sub = await subscriber.subscribe({ + stream: stream.id, + // @ts-expect-error + groupKeys: keys, + }, done.wrap((parsedContent, streamMessage) => { + expect(parsedContent).toEqual(msg) + // Check signature stuff + expect(streamMessage.signatureType).toBe(StreamMessage.SIGNATURE_TYPES.ETH) + expect(streamMessage.getPublisherId()) + expect(streamMessage.signature) + })) + + publisher.once('error', done.reject) + await publisher.setNextGroupKey(stream.id, groupKey) + // Publish after subscribed + await Promise.all([ + publisher.publish(stream.id, msg), + done, + ]) + // All good, unsubscribe + await publisher.unsubscribe(sub) + }) + + it('client.subscribe can get the group key and decrypt encrypted message using an RSA key pair', async () => { + const done = Defer() + const msg = Msg() + const groupKey = GroupKey.generate() + // subscribe without knowing the group key to decrypt stream messages + await grantSubscriberPermissions() + const sub = await subscriber.subscribe({ + stream: stream.id, + }, done.wrap((parsedContent, streamMessage) => { + expect(parsedContent).toEqual(msg) + + // Check signature stuff + expect(streamMessage.signatureType).toBe(StreamMessage.SIGNATURE_TYPES.ETH) + expect(streamMessage.getPublisherId()) + expect(streamMessage.signature) + })) + sub.once('error', done.reject) + + await publisher.setNextGroupKey(stream.id, groupKey) + const onEncryptionMessageErr = checkEncryptionMessages(publisher) + + await Promise.all([ + publisher.publish(stream.id, msg), + done, + ]) + onEncryptionMessageErr.resolve(undefined) // will be ignored if errored + await onEncryptionMessageErr + // All good, unsubscribe + await publisher.unsubscribe(sub) + }, TIMEOUT) + + it('changing group key injects group key into next stream message', async () => { + const done = Defer() + const msgs = [Msg(), Msg(), Msg()] + const received: any[] = [] + await grantSubscriberPermissions() + // subscribe without knowing the group key to decrypt stream messages + const sub = await subscriber.subscribe({ + stream: stream.id, + }, done.wrapError((_parsedContent, streamMessage) => { + // Check signature stuff + received.push(streamMessage) + if (received.length === msgs.length) { + done.resolve(undefined) + } + })) + + sub.once('error', done.reject) + + const onEncryptionMessageErr = checkEncryptionMessages(publisher) + // id | groupKeyId | newGroupKey (encrypted by groupKeyId) + // msg1 gk2 - + // msg2 gk2 gk3 + // msg3 gk3 - + const groupKey1 = GroupKey.generate() + const groupKey2 = GroupKey.generate() + await publisher.setNextGroupKey(stream.id, groupKey1) + await publisher.publish(stream.id, msgs[0]) + await publisher.setNextGroupKey(stream.id, groupKey2) + await publisher.publish(stream.id, msgs[1]) + await publisher.publish(stream.id, msgs[2]) + await done + expect(received.map((m) => m.getParsedContent())).toEqual(msgs) + received.forEach((streamMessage, index) => { + expect(streamMessage.signatureType).toBe(StreamMessage.SIGNATURE_TYPES.ETH) + expect(streamMessage.getPublisherId()) + expect(streamMessage.signature) + switch (index) { + case 0: { + expect(streamMessage.newGroupKey).toEqual(null) + expect(streamMessage.groupKeyId).toEqual(groupKey1.id) + break + } + case 1: { + expect(streamMessage.newGroupKey).toEqual(groupKey2) + expect(streamMessage.groupKeyId).toEqual(groupKey1.id) + break + } + case 2: { + expect(streamMessage.newGroupKey).toEqual(null) + expect(streamMessage.groupKeyId).toEqual(groupKey2.id) + break + } + default: { + throw new Error(`should not get here: ${index}`) + } + + } + }) + + onEncryptionMessageErr.resolve(undefined) // will be ignored if errored + await onEncryptionMessageErr + // All good, unsubscribe + await publisher.unsubscribe(sub) + }, TIMEOUT) + + it('errors if rotating group key for no stream', async () => { + expect(async () => ( + // @ts-expect-error + publisher.rotateGroupKey() + )).rejects.toThrow('streamId') + }) + + it('errors if setting group key for no stream', async () => { + expect(async () => ( + // @ts-expect-error + publisher.setNextGroupKey(undefined, GroupKey.generate()) + )).rejects.toThrow('streamId') + }) + + it('allows other users to get group key', async () => { + const onEncryptionMessageErr = checkEncryptionMessages(publisher) + const onEncryptionMessageErr2 = checkEncryptionMessages(subscriber) + const done = Defer() + const msg = Msg() + await grantSubscriberPermissions() + // subscribe without knowing the group key to decrypt stream messages + const sub = await subscriber.subscribe({ + stream: stream.id, + }, done.wrap((parsedContent, streamMessage) => { + expect(parsedContent).toEqual(msg) + + // Check signature stuff + expect(streamMessage.signatureType).toBe(StreamMessage.SIGNATURE_TYPES.ETH) + expect(streamMessage.getPublisherId()) + expect(streamMessage.signature) + })) + sub.once('error', done.reject) + + const groupKey = GroupKey.generate() + await publisher.setNextGroupKey(stream.id, groupKey) + + await Promise.all([ + publisher.publish(stream.id, msg), + done, + ]) + onEncryptionMessageErr.resolve(undefined) // will be ignored if errored + await onEncryptionMessageErr + onEncryptionMessageErr2.resolve(undefined) // will be ignored if errored + await onEncryptionMessageErr2 + }, TIMEOUT) + + it('does not encrypt messages in stream without groupkey', async () => { + const name = uid('stream') + const stream2 = await publisher.createStream({ + name, + requireEncryptedData: false, + }) + + let didFindStream2 = false + + function checkEncryptionMessagesPerStream(testClient: StreamrClient) { + const onSendTest = Defer() + testClient.connection.on('_send', onSendTest.wrapError((sendingMsg) => { + // check encryption is as expected + const { streamMessage } = sendingMsg + if (!streamMessage) { + return + } + + if (streamMessage.getStreamId() === stream2.id) { + didFindStream2 = true + // stream2 always unencrypted + expect(streamMessage.encryptionType).toEqual(StreamMessage.ENCRYPTION_TYPES.NONE) + return + } + + if (streamMessage.messageType === StreamMessage.MESSAGE_TYPES.MESSAGE) { + expect(streamMessage.encryptionType).toEqual(StreamMessage.ENCRYPTION_TYPES.AES) + } else if (streamMessage.messageType === StreamMessage.MESSAGE_TYPES.GROUP_KEY_RESPONSE) { + expect(streamMessage.encryptionType).toEqual(StreamMessage.ENCRYPTION_TYPES.RSA) + } else { + expect(streamMessage.encryptionType).toEqual(StreamMessage.ENCRYPTION_TYPES.NONE) + } + })) + return onSendTest + } + + async function testSub(testStream: Stream) { + const NUM_MESSAGES = 5 + const done = Defer() + const received: any = [] + await grantSubscriberPermissions({ stream: testStream }) + const sub = await subscriber.subscribe({ + stream: testStream.id, + }, done.wrapError((parsedContent) => { + received.push(parsedContent) + if (received.length === NUM_MESSAGES) { + done.resolve(undefined) + } + })) + sub.once('error', done.reject) + + const published = await publishTestMessages(NUM_MESSAGES, { + stream: testStream, + }) + + await done + + expect(received).toEqual(published) + } + + const onEncryptionMessageErr = checkEncryptionMessagesPerStream(publisher) + + const groupKey = GroupKey.generate() + await publisher.setNextGroupKey(stream.id, groupKey) + + await Promise.all([ + testSub(stream), + testSub(stream2), + ]) + onEncryptionMessageErr.resolve(undefined) // will be ignored if errored + await onEncryptionMessageErr + expect(didFindStream2).toBeTruthy() + }, TIMEOUT) + + it('sets group key per-stream', async () => { + const name = uid('stream') + const stream2 = await publisher.createStream({ + name, + requireEncryptedData: true, + }) + + const groupKey = GroupKey.generate() + await publisher.setNextGroupKey(stream.id, groupKey) + const groupKey2 = GroupKey.generate() + await publisher.setNextGroupKey(stream2.id, groupKey2) + + function checkEncryptionMessagesPerStream(testClient: StreamrClient) { + const onSendTest = Defer() + testClient.connection.on('_send', onSendTest.wrapError((sendingMsg) => { + // check encryption is as expected + const { streamMessage } = sendingMsg + if (!streamMessage) { + return + } + + if (streamMessage.getStreamId() === stream2.id) { + expect(streamMessage.groupKeyId).toEqual(groupKey2.id) + } + + if (streamMessage.getStreamId() === stream.id) { + expect(streamMessage.groupKeyId).toEqual(groupKey.id) + } + + if (streamMessage.messageType === StreamMessage.MESSAGE_TYPES.MESSAGE) { + expect(streamMessage.encryptionType).toEqual(StreamMessage.ENCRYPTION_TYPES.AES) + } else if (streamMessage.messageType === StreamMessage.MESSAGE_TYPES.GROUP_KEY_RESPONSE) { + expect(streamMessage.encryptionType).toEqual(StreamMessage.ENCRYPTION_TYPES.RSA) + } else { + expect(streamMessage.encryptionType).toEqual(StreamMessage.ENCRYPTION_TYPES.NONE) + } + })) + return onSendTest + } + + async function testSub(testStream: Stream) { + const NUM_MESSAGES = 5 + const done = Defer() + const received: any[] = [] + await grantSubscriberPermissions({ stream: testStream }) + const sub = await subscriber.subscribe({ + stream: testStream.id, + }, done.wrapError((parsedContent) => { + received.push(parsedContent) + if (received.length === NUM_MESSAGES) { + done.resolve(undefined) + } + })) + sub.once('error', done.reject) + + const published = await publishTestMessages(NUM_MESSAGES, { + stream: testStream, + }) + + await done + + expect(received).toEqual(published) + } + + const onEncryptionMessageErr = checkEncryptionMessagesPerStream(publisher) + + await Promise.all([ + testSub(stream), + testSub(stream2), + ]) + onEncryptionMessageErr.resolve(undefined) // will be ignored if errored + await onEncryptionMessageErr + }, TIMEOUT) + + it('client.subscribe can get the group key and decrypt multiple encrypted messages using an RSA key pair', async () => { + // subscribe without knowing publisher the group key to decrypt stream messages + await grantSubscriberPermissions() + const sub = await subscriber.subscribe({ + stream: stream.id, + }) + + const groupKey = GroupKey.generate() + await publisher.setNextGroupKey(stream.id, groupKey) + const published = await publishTestMessages() + + const received = [] + for await (const msg of sub) { + received.push(msg.getParsedContent()) + if (received.length === published.length) { + return + } + } + + expect(received).toEqual(published) + + // All good, unsubscribe + await subscriber.unsubscribe(sub) + }, TIMEOUT) + + it('subscribe with changing group key', async () => { + // subscribe without knowing the group key to decrypt stream messages + await grantSubscriberPermissions() + const sub = await subscriber.subscribe({ + stream: stream.id, + }) + + const published = await publishTestMessages(5, { + async beforeEach() { + await publisher.rotateGroupKey(stream.id) + } + }) + + const received = [] + for await (const msg of sub) { + received.push(msg.getParsedContent()) + if (received.length === published.length) { + return + } + } + + expect(received).toEqual(published) + + // All good, unsubscribe + await publisher.unsubscribe(sub) + }, TIMEOUT) + + it('client.resend last can get the historical keys for previous encrypted messages', async () => { + // Publish encrypted messages with different keys + const published = await publishTestMessages(5, { + waitForLast: true, + async beforeEach() { + await publisher.rotateGroupKey(stream.id) + } + }) + + // resend without knowing the historical keys + await grantSubscriberPermissions() + const sub = await subscriber.resend({ + stream: stream.id, + resend: { + last: 2, + }, + }) + + const received = await sub.collect() + + expect(received).toEqual(published.slice(-2)) + await publisher.unsubscribe(sub) + }, TIMEOUT) + + it('client.subscribe with resend last can get the historical keys for previous encrypted messages', async () => { + // Publish encrypted messages with different keys + const published = await publishTestMessages(5, { + waitForLast: true, + async beforeEach() { + await publisher.rotateGroupKey(stream.id) + } + }) + + await grantSubscriberPermissions() + // subscribe with resend without knowing the historical keys + const sub = await subscriber.subscribe({ + stream: stream.id, + resend: { + last: 2, + }, + }) + + const received = [] + for await (const msg of sub) { + received.push(msg.getParsedContent()) + if (received.length === 2) { + break + } + } + + expect(received).toEqual(published.slice(-2)) + await subscriber.unsubscribe(sub) + }, TIMEOUT) + + it('fails gracefully if cannot decrypt', async () => { + const MAX_MESSAGES = 10 + const groupKey = GroupKey.generate() + const keys = { + [stream.id]: { + [groupKey.id]: groupKey, + } + } + + await publisher.setNextGroupKey(stream.id, groupKey) + + const BAD_INDEX = 6 + let count = 0 + const { parse } = subscriber.connection + subscriber.connection.parse = (...args) => { + const msg = parse.call(subscriber.connection, ...args) + if (!('streamMessage' in msg)) { + return msg + } + + if (count === BAD_INDEX) { + // @ts-expect-error + msg.streamMessage.groupKeyId = 'badgroupkey' + } + + count += 1 + return msg + } + + await grantSubscriberPermissions() + const sub = await subscriber.subscribe({ + stream: stream.id, + // @ts-expect-error + groupKeys: keys, + }) + + const onSubError = jest.fn((err) => { + expect(err).toBeInstanceOf(Error) + expect(err.message).toMatch('decrypt') + }) + + sub.on('error', onSubError) + + // Publish after subscribed + const published = await publishTestMessages(MAX_MESSAGES, { + timestamp: 1111111, + }) + + const received = [] + for await (const m of sub) { + received.push(m.getParsedContent()) + if (received.length === published.length - 1) { + break + } + } + + expect(received).toEqual([ + ...published.slice(0, BAD_INDEX), + ...published.slice(BAD_INDEX + 1, MAX_MESSAGES) + ]) + + expect(onSubError).toHaveBeenCalledTimes(1) + }) + }) + + describe('revoking permissions', () => { + async function testRevokeDuringSubscribe({ + maxMessages = 6, + revokeAfter = Math.round(maxMessages / 2), + }: { + maxMessages?: number + revokeAfter?: number + } = {}) { + // this has publisher & subscriber clients + // publisher begins publishing `maxMessages` messages + // subscriber recieves messages + // after publisher publishes `revokeAfter` messages, + // and subscriber receives the last message + // subscriber has subscribe permission removed + // and publisher rekeys the stream. + // Publisher then keep publishing messages with the new key. + // The subscriber should error on the next message, and unsubscribe + // due to permission change. + // check that subscriber only got messages from before permission revoked + // and subscriber errored with something about group key or + // permissions + + await publisher.rotateGroupKey(stream.id) + + await stream.grantPermission(StreamOperation.STREAM_GET, await subscriber.getPublisherId()) + const subPermission = await stream.grantPermission(StreamOperation.STREAM_SUBSCRIBE, await subscriber.getPublisherId()) + + const sub = await subscriber.subscribe({ + stream: stream.id, + }) + + const errs: Error[] = [] + const onSubError = jest.fn((err: Error) => { + errs.push(err) + throw err // this should trigger unsub + }) + + sub.on('error', onSubError) + + const received: any[] = [] + // Publish after subscribed + let count = 0 + const gotMessages = Defer() + // do publish in background otherwise permission is revoked before subscriber starts processing + const publishTask = publishTestMessages(maxMessages, { + timestamp: 1111111, + async afterEach() { + count += 1 + publisher.debug('PUBLISHED %d of %d', count, maxMessages) + if (count === revokeAfter) { + await gotMessages + await stream.revokePermission(subPermission.id) + await publisher.rekey(stream.id) + } + } + }) + + subscriber.debug('\n\n1\n\n') + let t!: ReturnType + const timedOut = jest.fn() + try { + await expect(async () => { + for await (const m of sub) { + subscriber.debug('got', m.getParsedContent()) + received.push(m.getParsedContent()) + if (received.length === revokeAfter) { + gotMessages.resolve(undefined) + clearTimeout(t) + t = setTimeout(() => { + timedOut() + sub.cancel() + }, 6000) + } + + if (received.length === maxMessages) { + clearTimeout(t) + break + } + } + }).rejects.toThrow(/not a subscriber|Could not get GroupKey/) + } finally { + clearTimeout(t) + // run in finally to ensure publish promise finishes before + // continuing no matter the result of the expect call above + const published = await publishTask + + expect(received).toEqual([ + ...published.slice(0, revokeAfter), + ]) + + expect(onSubError).toHaveBeenCalledTimes(1) + expect(timedOut).toHaveBeenCalledTimes(0) + } + } + + describe('low cache maxAge', () => { + beforeEach(async () => { + await setupPublisherSubscriberClients({ + cache: { + maxAge: 1, + } + }) + }) + beforeEach(async () => { + await setupStream() + }) + it('fails gracefully if permission revoked after first message', async () => { + await testRevokeDuringSubscribe({ maxMessages: 6, revokeAfter: 1 }) + }) + it('fails gracefully if permission revoked after some messages', async () => { + await testRevokeDuringSubscribe({ maxMessages: 6, revokeAfter: 3 }) + }) + }) + + describe('high cache maxAge', () => { + beforeEach(async () => { + await setupPublisherSubscriberClients({ + cache: { + maxAge: 9999999, + } + }) + }) + + beforeEach(async () => { + await setupStream() + }) + + it('fails gracefully if permission revoked after first message', async () => { + await testRevokeDuringSubscribe({ maxMessages: 6, revokeAfter: 1 }) + }) + + it('fails gracefully if permission revoked after some messages', async () => { + await testRevokeDuringSubscribe({ maxMessages: 6, revokeAfter: 3 }) + }) + }) + }) +}) diff --git a/test/integration/EncryptionKeyPersistence.test.ts b/test/integration/EncryptionKeyPersistence.test.ts new file mode 100644 index 000000000..e0815b3bc --- /dev/null +++ b/test/integration/EncryptionKeyPersistence.test.ts @@ -0,0 +1,278 @@ +import { wait } from 'streamr-test-utils' + +import { describeRepeats, fakePrivateKey, uid, getPublishTestMessages, addAfterFn } from '../utils' +import { StreamrClient } from '../../src/StreamrClient' +import { Stream, StreamOperation } from '../../src/stream' +import { GroupKey } from '../../src/stream/encryption/Encryption' +import Connection from '../../src/Connection' + +import config from './config' + +const TIMEOUT = 10 * 1000 + +describeRepeats('Encryption Key Persistence', () => { + let expectErrors = 0 // check no errors by default + let errors: Error[] = [] + let onError = jest.fn() + const getOnError = (errs: Error[]) => jest.fn((err) => { + errs.push(err) + }) + + let publisher: StreamrClient + let subscriber: StreamrClient + let stream: Stream + let publishTestMessages: ReturnType + const addAfter = addAfterFn() + + const createClient = (opts = {}) => { + const c = new StreamrClient({ + ...config.clientOptions, + auth: { + privateKey: fakePrivateKey(), + }, + autoConnect: false, + autoDisconnect: false, + // @ts-expect-error + disconnectDelay: 1, + publishAutoDisconnectDelay: 50, + maxRetries: 2, + ...opts, + }) + c.onError = jest.fn() + c.on('error', onError) + + return c + } + + beforeEach(() => { + errors = [] + expectErrors = 0 + onError = getOnError(errors) + }) + + afterEach(async () => { + await wait(0) + // ensure no unexpected errors + expect(errors).toHaveLength(expectErrors) + if (publisher) { + expect(publisher.onError).toHaveBeenCalledTimes(expectErrors) + } + }) + + afterEach(async () => { + await wait(0) + if (publisher) { + publisher.debug('disconnecting after test') + await publisher.disconnect() + } + + if (subscriber) { + subscriber.debug('disconnecting after test') + await subscriber.disconnect() + } + + const openSockets = Connection.getOpen() + if (openSockets !== 0) { + await Connection.closeOpen() + throw new Error(`sockets not closed: ${openSockets}`) + } + }) + + let publisherPrivateKey: string + let subscriberPrivateKey: string + + async function setupPublisher(opts?: any) { + const client = createClient(opts) + await Promise.all([ + client.session.getSessionToken(), + client.connect(), + ]) + + const name = uid('stream') + stream = await client.createStream({ + name, + requireEncryptedData: true, + }) + + await stream.addToStorageNode(config.clientOptions.storageNode.address) + + publishTestMessages = getPublishTestMessages(client, { + stream, + waitForLast: true, + }) + return client + } + + beforeEach(async () => { + publisherPrivateKey = fakePrivateKey() + publisher = await setupPublisher({ + id: 'publisher', + auth: { + privateKey: publisherPrivateKey, + } + }) + subscriberPrivateKey = fakePrivateKey() + subscriber = createClient({ + id: 'subscriber', + autoConnect: true, + autoDisconnect: true, + auth: { + privateKey: subscriberPrivateKey, + } + }) + const otherUser = await subscriber.getUserInfo() + await stream.grantPermission(StreamOperation.STREAM_GET, otherUser.username) + await stream.grantPermission(StreamOperation.STREAM_SUBSCRIBE, otherUser.username) + const groupKey = GroupKey.generate() + await publisher.setNextGroupKey(stream.id, groupKey) + }) + + it('publisher persists group key', async () => { + // ensure publisher can read a persisted group key + // 1. publish some messages with publisher + // 2. then disconnect publisher + // 3. create new publisher with same key + // 4. subscribe with subscriber + // because original publisher is disconnected + // subscriber will need to ask new publisher + // for group keys, which the new publisher will have to read from + // persistence + const published = await publishTestMessages(5) + await publisher.disconnect() + const publisher2 = createClient({ + id: 'publisher2', + auth: { + privateKey: publisherPrivateKey, + } + }) + + addAfter(async () => { + await publisher2.disconnect() + }) + + await publisher2.connect() + + // TODO: this should probably happen automatically if there are keys + // also probably needs to create a connection handle + await publisher2.publisher.startKeyExchange() + + const sub = await subscriber.subscribe({ + stream: stream.id, + resend: { + last: 5, + } + }) + const received = [] + for await (const m of sub) { + received.push(m.getParsedContent()) + if (received.length === published.length) { + break + } + } + + expect(received).toEqual(published) + }, 2 * TIMEOUT) + + it('subscriber persists group key', async () => { + // we want to check that subscriber can read a group key + // persisted by another subscriber: + // 1. create publisher and subscriber + // 2. after subscriber gets first message + // 3. disconnect both subscriber and publisher + // 4. then create a new subscriber with same key as original subscriber + // 5. and subscribe to same stream. + // this should pick up group key persisted by first subscriber + // publisher is disconnected, so can't ask for new group keys + const sub = await subscriber.subscribe({ + stream: stream.id, + }) + const published = await publishTestMessages(5) + + const received = [] + for await (const m of sub) { + received.push(m.getParsedContent()) + if (received.length === 1) { + break + } + } + await subscriber.disconnect() + await publisher.disconnect() + + const subscriber2 = createClient({ + id: 'subscriber2', + auth: { + privateKey: subscriberPrivateKey + } + }) + + addAfter(async () => { + await subscriber2.disconnect() + }) + + await subscriber2.connect() + const sub2 = await subscriber2.subscribe({ + stream: stream.id, + resend: { + last: 5 + } + }) + + const received2 = [] + for await (const m of sub2) { + received2.push(m.getParsedContent()) + if (received2.length === published.length) { + break + } + } + expect(received2).toEqual(published) + expect(received).toEqual(published.slice(0, 1)) + }, 2 * TIMEOUT) + + it('can run multiple publishers in parallel', async () => { + const sub = await subscriber.subscribe({ + stream: stream.id, + }) + + // ensure publishers don't clobber each others data + const publisher2 = createClient({ + id: 'publisher2', + auth: { + privateKey: publisherPrivateKey, + } + }) + + addAfter(async () => { + await publisher2.disconnect() + }) + + await publisher2.connect() + const publishTestMessages2 = getPublishTestMessages(publisher2, { + stream, + waitForLast: true, + }) + + const [published1, published2] = await Promise.all([ + publishTestMessages(5), + publishTestMessages2(6), // use different lengths so we can differentiate who published what + ]) + + const received1 = [] + const received2 = [] + for await (const m of sub) { + const content = m.getParsedContent() + // 'n of 6' messages belong to publisher2 + if (content.value.endsWith('of 6')) { + received2.push(content) + } else { + received1.push(content) + } + + if (received1.length === published1.length && received2.length === published2.length) { + break + } + } + + expect(received1).toEqual(published1) + expect(received2).toEqual(published2) + }, 2 * TIMEOUT) +}) diff --git a/test/integration/SubscriberResends.test.ts b/test/integration/SubscriberResends.test.ts index 611f47382..2097f9b87 100644 --- a/test/integration/SubscriberResends.test.ts +++ b/test/integration/SubscriberResends.test.ts @@ -286,8 +286,7 @@ describeRepeats('resends', () => { client.connection.enableAutoDisconnect(false) }) client.connection.enableAutoConnect() - // @ts-expect-error - client.connection.enableAutoDisconnect(600) // set 0 delay + client.connection.enableAutoDisconnect(0) // set 0 delay const sub = await subscriber.resend({ streamId: stream.id, last: published.length, @@ -475,8 +474,7 @@ describeRepeats('resends', () => { it('can end inside resend', async () => { const unsubscribeEvents: any[] = [] - // @ts-expect-error - client.connection.on(ControlMessage.TYPES.UnsubscribeResponse, (m) => { + client.connection.on(String(ControlMessage.TYPES.UnsubscribeResponse), (m) => { unsubscribeEvents.push(m) }) const sub = await subscriber.resendSubscribe({ diff --git a/test/unit/Encryption.test.ts b/test/unit/Encryption.test.ts index 0919aedef..cb2eb9419 100644 --- a/test/unit/Encryption.test.ts +++ b/test/unit/Encryption.test.ts @@ -3,7 +3,7 @@ import crypto from 'crypto' import { ethers } from 'ethers' import { MessageLayer } from 'streamr-client-protocol' -import EncryptionUtil, { GroupKey } from '../../src/stream/Encryption' +import EncryptionUtil, { GroupKey } from '../../src/stream/encryption/Encryption' const { StreamMessage, MessageID } = MessageLayer diff --git a/test/unit/GroupKeyStore.test.ts b/test/unit/GroupKeyStore.test.ts new file mode 100644 index 000000000..571844322 --- /dev/null +++ b/test/unit/GroupKeyStore.test.ts @@ -0,0 +1,199 @@ +import crypto from 'crypto' +import { GroupKey } from '../../src/stream/encryption/Encryption' +import GroupKeyStore, { GroupKeyPersistence } from '../../src/stream/encryption/GroupKeyStore' +import { uid, addAfterFn, describeRepeats } from '../utils' + +describeRepeats('GroupKeyStore', () => { + let clientId: string + let streamId: string + let store: GroupKeyStore + + beforeEach(() => { + clientId = `0x${crypto.randomBytes(20).toString('hex')}` + streamId = uid('stream') + store = new GroupKeyStore({ + clientId, + streamId, + groupKeys: [], + }) + }) + + afterEach(async () => { + if (!store) { return } + await store.clear() + }) + + it('can get set and delete', async () => { + const groupKey = GroupKey.generate() + expect(await store.get(groupKey.id)).toBeFalsy() + expect(await store.add(groupKey)).toBeTruthy() + expect(await store.get(groupKey.id)).toEqual(groupKey) + expect(await store.clear()).toBeTruthy() + expect(await store.clear()).toBeFalsy() + expect(await store.get(groupKey.id)).toBeFalsy() + }) + + it('can set next and use', async () => { + const groupKey = GroupKey.generate() + await store.setNextGroupKey(groupKey) + expect(await store.useGroupKey()).toEqual([groupKey, undefined]) + expect(await store.useGroupKey()).toEqual([groupKey, undefined]) + const groupKey2 = GroupKey.generate() + await store.setNextGroupKey(groupKey2) + expect(await store.useGroupKey()).toEqual([groupKey, groupKey2]) + expect(await store.useGroupKey()).toEqual([groupKey2, undefined]) + }) + + it('can set next in parallel and use', async () => { + const groupKey = GroupKey.generate() + const groupKey2 = GroupKey.generate() + await Promise.all([ + store.setNextGroupKey(groupKey), + store.setNextGroupKey(groupKey2), + ]) + expect(await store.useGroupKey()).toEqual([groupKey, undefined]) + }) +}) + +describeRepeats('PersistentStore', () => { + let clientId: string + let streamId: string + let store: GroupKeyPersistence + + const addAfter = addAfterFn() + + beforeEach(() => { + clientId = `0x${crypto.randomBytes(20).toString('hex')}` + streamId = uid('stream') + store = new GroupKeyPersistence({ + clientId, + streamId, + }) + }) + + afterEach(async () => { + if (!store) { return } + await store.clear() + }) + + it('can get set and delete', async () => { + const groupKey = GroupKey.generate() + expect(await store.has(groupKey.id)).toBeFalsy() + expect(await store.size()).toBe(0) + expect(await store.get(groupKey.id)).toBeFalsy() + expect(await store.delete(groupKey.id)).toBeFalsy() + expect(await store.clear()).toBeFalsy() + + expect(await store.add(groupKey)).toBeTruthy() + expect(await store.add(groupKey)).toBeFalsy() + expect(await store.has(groupKey.id)).toBeTruthy() + expect(await store.get(groupKey.id)).toEqual(groupKey) + expect(await store.size()).toBe(1) + expect(await store.delete(groupKey.id)).toBeTruthy() + + expect(await store.has(groupKey.id)).toBeFalsy() + expect(await store.size()).toBe(0) + + expect(await store.get(groupKey.id)).toBeFalsy() + expect(await store.delete(groupKey.id)).toBeFalsy() + expect(await store.add(groupKey)).toBeTruthy() + expect(await store.size()).toBe(1) + expect(await store.clear()).toBeTruthy() + expect(await store.size()).toBe(0) + }) + + it('can get set and delete in parallel', async () => { + const store2 = new GroupKeyPersistence({ + clientId, + streamId, + }) + addAfter(() => store2.clear()) + + for (let i = 0; i < 5; i++) { + const groupKey = GroupKey.generate() + /* eslint-disable no-await-in-loop, no-loop-func, promise/always-return */ + const tasks = [ + // test adding to same store in parallel doesn't break + // add key to store1 twice in parallel + store.add(groupKey).then(async () => { + // immediately check exists in store2 + expect(await store2.has(groupKey.id)).toBeTruthy() + }), + store.add(groupKey).then(async () => { + // immediately check exists in store2 + expect(await store2.has(groupKey.id)).toBeTruthy() + }), + // test adding to another store at same time doesn't break + // add to store2 in parallel + store2.add(groupKey).then(async () => { + // immediately check exists in store1 + expect(await store.has(groupKey.id)).toBeTruthy() + }), + ] + + await Promise.allSettled(tasks) + await Promise.all(tasks) + /* eslint-enable no-await-in-loop, no-loop-func, promise/always-return */ + } + }) + + it('does not conflict with other streamIds', async () => { + const streamId2 = uid('stream') + const store2 = new GroupKeyPersistence({ + clientId, + streamId: streamId2, + }) + + addAfter(() => store2.clear()) + + const groupKey = GroupKey.generate() + await store.add(groupKey) + expect(await store2.has(groupKey.id)).toBeFalsy() + expect(await store2.get(groupKey.id)).toBeFalsy() + expect(await store2.size()).toBe(0) + expect(await store2.delete(groupKey.id)).toBeFalsy() + expect(await store2.clear()).toBeFalsy() + expect(await store2.clear()).toBeFalsy() + expect(await store.get(groupKey.id)).toEqual(groupKey) + }) + + it('does not conflict with other clientIds', async () => { + const clientId2 = `0x${crypto.randomBytes(20).toString('hex')}` + const store2 = new GroupKeyPersistence({ + clientId: clientId2, + streamId, + }) + + addAfter(() => store2.clear()) + + const groupKey = GroupKey.generate() + await store.add(groupKey) + expect(await store2.has(groupKey.id)).toBeFalsy() + expect(await store2.get(groupKey.id)).toBeFalsy() + expect(await store2.size()).toBe(0) + expect(await store2.delete(groupKey.id)).toBeFalsy() + expect(await store2.clear()).toBeFalsy() + expect(await store2.clear()).toBeFalsy() + expect(await store.get(groupKey.id)).toEqual(groupKey) + }) + + it('does not conflict with other clientIds', async () => { + const clientId2 = `0x${crypto.randomBytes(20).toString('hex')}` + const store2 = new GroupKeyPersistence({ + clientId: clientId2, + streamId, + }) + + addAfter(() => store2.clear()) + + const groupKey = GroupKey.generate() + await store.add(groupKey) + expect(await store2.has(groupKey.id)).toBeFalsy() + expect(await store2.get(groupKey.id)).toBeFalsy() + expect(await store2.size()).toBe(0) + expect(await store2.delete(groupKey.id)).toBeFalsy() + expect(await store2.clear()).toBeFalsy() + expect(await store2.clear()).toBeFalsy() + expect(await store.get(groupKey.id)).toEqual(groupKey) + }) +}) diff --git a/webpack.config.js b/webpack.config.js index 2f46fa51a..2ca3caa7e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -103,6 +103,10 @@ module.exports = (env, argv) => { 'node-fetch': path.resolve(__dirname, './src/shim/node-fetch.js'), 'node-webcrypto-ossl': path.resolve(__dirname, 'src/shim/crypto.js'), 'streamr-client-protocol': path.resolve(__dirname, 'node_modules/streamr-client-protocol/dist/src'), + // swap out ServerPersistentStore for BrowserPersistentStore + [path.resolve(__dirname, 'src/stream/encryption/ServerPersistentStore')]: ( + path.resolve(__dirname, 'src/stream/encryption/BrowserPersistentStore') + ), } }, plugins: [