diff --git a/.github/workflows/pr.components.yml b/.github/workflows/pr.components.yml index 951e6bf75..73597bd1f 100644 --- a/.github/workflows/pr.components.yml +++ b/.github/workflows/pr.components.yml @@ -30,9 +30,9 @@ jobs: run: yarn test working-directory: packages/components - - name: Lint all - run: yarn lint - name: Build all run: yarn build:all + - name: Lint all + run: yarn lint - name: Test all run: yarn test diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7df49cddb..8d0c7c95c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -18,10 +18,10 @@ jobs: uses: ruby/setup-ruby@v1 - name: Install dependencies run: yarn install - - name: ESLint - run: yarn lint - name: Build run: yarn build:all + - name: ESLint + run: yarn lint - name: Build Android run: yarn android:bundle - name: Test diff --git a/.gitignore b/.gitignore index d370cf0a4..dfcedec78 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ codeqldb coverage lerna-debug.log +packages/encryption/dist + **/.pnp.* **/.yarn/* !.yarn/patches @@ -26,4 +28,4 @@ lerna-debug.log !.yarn/releases !.yarn/sdks !.yarn/versions -!.yarn/cache \ No newline at end of file +!.yarn/cache diff --git a/.yarn/cache/@standardnotes-encryption-npm-1.8.22-a04b05c8a4-7a571c4b25.zip b/.yarn/cache/@standardnotes-encryption-npm-1.8.22-a04b05c8a4-7a571c4b25.zip deleted file mode 100644 index cf9934fff..000000000 Binary files a/.yarn/cache/@standardnotes-encryption-npm-1.8.22-a04b05c8a4-7a571c4b25.zip and /dev/null differ diff --git a/.yarn/cache/@standardnotes-encryption-npm-1.8.23-3dd93922b7-b86df01dc7.zip b/.yarn/cache/@standardnotes-encryption-npm-1.8.23-3dd93922b7-b86df01dc7.zip deleted file mode 100644 index faead78cf..000000000 Binary files a/.yarn/cache/@standardnotes-encryption-npm-1.8.23-3dd93922b7-b86df01dc7.zip and /dev/null differ diff --git a/.yarn/cache/@types-node-npm-18.0.1-35e22b3e26-be14b251c5.zip b/.yarn/cache/@types-node-npm-18.0.1-35e22b3e26-be14b251c5.zip new file mode 100644 index 000000000..dd0248a07 Binary files /dev/null and b/.yarn/cache/@types-node-npm-18.0.1-35e22b3e26-be14b251c5.zip differ diff --git a/.yarn/cache/@typescript-eslint-eslint-plugin-npm-5.30.4-08f53c0ede-9b9290448b.zip b/.yarn/cache/@typescript-eslint-eslint-plugin-npm-5.30.4-08f53c0ede-9b9290448b.zip new file mode 100644 index 000000000..5c0c3a57e Binary files /dev/null and b/.yarn/cache/@typescript-eslint-eslint-plugin-npm-5.30.4-08f53c0ede-9b9290448b.zip differ diff --git a/.yarn/cache/@typescript-eslint-scope-manager-npm-5.30.4-8b6cf23765-3da442dc11.zip b/.yarn/cache/@typescript-eslint-scope-manager-npm-5.30.4-8b6cf23765-3da442dc11.zip new file mode 100644 index 000000000..7d7eed3f8 Binary files /dev/null and b/.yarn/cache/@typescript-eslint-scope-manager-npm-5.30.4-8b6cf23765-3da442dc11.zip differ diff --git a/.yarn/cache/@typescript-eslint-type-utils-npm-5.30.4-f2696bf1f1-552eb1a5b1.zip b/.yarn/cache/@typescript-eslint-type-utils-npm-5.30.4-f2696bf1f1-552eb1a5b1.zip new file mode 100644 index 000000000..41dbfcff1 Binary files /dev/null and b/.yarn/cache/@typescript-eslint-type-utils-npm-5.30.4-f2696bf1f1-552eb1a5b1.zip differ diff --git a/.yarn/cache/@typescript-eslint-types-npm-5.30.4-c748bd84f1-06181c3355.zip b/.yarn/cache/@typescript-eslint-types-npm-5.30.4-c748bd84f1-06181c3355.zip new file mode 100644 index 000000000..59d23e95a Binary files /dev/null and b/.yarn/cache/@typescript-eslint-types-npm-5.30.4-c748bd84f1-06181c3355.zip differ diff --git a/.yarn/cache/@typescript-eslint-typescript-estree-npm-5.30.4-7c97ea55f3-1aaa414993.zip b/.yarn/cache/@typescript-eslint-typescript-estree-npm-5.30.4-7c97ea55f3-1aaa414993.zip new file mode 100644 index 000000000..ac2b73718 Binary files /dev/null and b/.yarn/cache/@typescript-eslint-typescript-estree-npm-5.30.4-7c97ea55f3-1aaa414993.zip differ diff --git a/.yarn/cache/@typescript-eslint-utils-npm-5.30.4-6bd4c125c1-0f680d3667.zip b/.yarn/cache/@typescript-eslint-utils-npm-5.30.4-6bd4c125c1-0f680d3667.zip new file mode 100644 index 000000000..75eb41528 Binary files /dev/null and b/.yarn/cache/@typescript-eslint-utils-npm-5.30.4-6bd4c125c1-0f680d3667.zip differ diff --git a/.yarn/cache/@typescript-eslint-visitor-keys-npm-5.30.4-19f4a2caf4-ec39680a89.zip b/.yarn/cache/@typescript-eslint-visitor-keys-npm-5.30.4-19f4a2caf4-ec39680a89.zip new file mode 100644 index 000000000..abbe8cf03 Binary files /dev/null and b/.yarn/cache/@typescript-eslint-visitor-keys-npm-5.30.4-19f4a2caf4-ec39680a89.zip differ diff --git a/.yarn/cache/eslint-plugin-prettier-npm-4.2.1-ba8e1240f1-b9e839d233.zip b/.yarn/cache/eslint-plugin-prettier-npm-4.2.1-ba8e1240f1-b9e839d233.zip new file mode 100644 index 000000000..382aa44c0 Binary files /dev/null and b/.yarn/cache/eslint-plugin-prettier-npm-4.2.1-ba8e1240f1-b9e839d233.zip differ diff --git a/.yarn/cache/reflect-metadata-npm-0.1.13-c525998e20-798d379a7b.zip b/.yarn/cache/reflect-metadata-npm-0.1.13-c525998e20-798d379a7b.zip new file mode 100644 index 000000000..244085ace Binary files /dev/null and b/.yarn/cache/reflect-metadata-npm-0.1.13-c525998e20-798d379a7b.zip differ diff --git a/packages/encryption/.eslintignore b/packages/encryption/.eslintignore new file mode 100644 index 000000000..f06235c46 --- /dev/null +++ b/packages/encryption/.eslintignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/packages/encryption/.eslintrc b/packages/encryption/.eslintrc new file mode 100644 index 000000000..86b280b2c --- /dev/null +++ b/packages/encryption/.eslintrc @@ -0,0 +1,10 @@ +{ + "extends": "../../.eslintrc", + "parserOptions": { + "project": "./linter.tsconfig.json" + }, + "rules": { + "@typescript-eslint/no-explicit-any": ["warn", { "ignoreRestArgs": true }], + "@typescript-eslint/no-non-null-assertion": "warn" + } +} diff --git a/packages/encryption/CHANGELOG.md b/packages/encryption/CHANGELOG.md new file mode 100644 index 000000000..49ad8caa1 --- /dev/null +++ b/packages/encryption/CHANGELOG.md @@ -0,0 +1,361 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## [1.8.24](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.23...@standardnotes/encryption@1.8.24) (2022-07-04) + +### Bug Fixes + +* add missing reflect-metadata package to all packages ([ce3a5bb](https://github.com/standardnotes/snjs/commit/ce3a5bbf3f1d2276ac4abc3eec3c6a44c8c3ba9b)) +* unit tests running ([9ddc55c](https://github.com/standardnotes/snjs/commit/9ddc55c59c781e2bcc366304a6d0cc88d0e0865d)) + +## [1.8.23](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.22...@standardnotes/encryption@1.8.23) (2022-06-29) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.8.22](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.21...@standardnotes/encryption@1.8.22) (2022-06-27) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.8.21](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.20...@standardnotes/encryption@1.8.21) (2022-06-27) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.8.20](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.19...@standardnotes/encryption@1.8.20) (2022-06-22) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.8.19](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.18...@standardnotes/encryption@1.8.19) (2022-06-20) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.8.18](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.17...@standardnotes/encryption@1.8.18) (2022-06-16) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.8.17](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.16...@standardnotes/encryption@1.8.17) (2022-06-16) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.8.16](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.15...@standardnotes/encryption@1.8.16) (2022-06-15) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.8.15](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.14...@standardnotes/encryption@1.8.15) (2022-06-10) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.8.14](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.13...@standardnotes/encryption@1.8.14) (2022-06-09) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.8.13](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.12...@standardnotes/encryption@1.8.13) (2022-06-09) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.8.12](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.11...@standardnotes/encryption@1.8.12) (2022-06-09) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.8.11](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.10...@standardnotes/encryption@1.8.11) (2022-06-06) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.8.10](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.9...@standardnotes/encryption@1.8.10) (2022-06-03) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.8.9](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.8...@standardnotes/encryption@1.8.9) (2022-06-02) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.8.8](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.7...@standardnotes/encryption@1.8.8) (2022-06-02) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.8.7](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.6...@standardnotes/encryption@1.8.7) (2022-06-01) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.8.6](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.5...@standardnotes/encryption@1.8.6) (2022-05-30) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.8.5](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.4...@standardnotes/encryption@1.8.5) (2022-05-27) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.8.4](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.3...@standardnotes/encryption@1.8.4) (2022-05-27) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.8.3](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.2...@standardnotes/encryption@1.8.3) (2022-05-24) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.8.2](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.1...@standardnotes/encryption@1.8.2) (2022-05-24) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.8.1](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.8.0...@standardnotes/encryption@1.8.1) (2022-05-22) + +**Note:** Version bump only for package @standardnotes/encryption + +# [1.8.0](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.7.12...@standardnotes/encryption@1.8.0) (2022-05-21) + +### Features + +* display controllers ([#743](https://github.com/standardnotes/snjs/issues/743)) ([5fadce3](https://github.com/standardnotes/snjs/commit/5fadce37f1b3f2f51b8a90c257bc666ac7710074)) + +## [1.7.12](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.7.11...@standardnotes/encryption@1.7.12) (2022-05-20) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.7.11](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.7.10...@standardnotes/encryption@1.7.11) (2022-05-20) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.7.10](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.7.9...@standardnotes/encryption@1.7.10) (2022-05-20) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.7.9](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.7.8...@standardnotes/encryption@1.7.9) (2022-05-18) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.7.8](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.7.7...@standardnotes/encryption@1.7.8) (2022-05-17) + +### Bug Fixes + +* workspace signout all ([0ac4501](https://github.com/standardnotes/snjs/commit/0ac45019428946016ef02384b07b8190378008fc)) + +## [1.7.7](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.7.6...@standardnotes/encryption@1.7.7) (2022-05-17) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.7.6](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.7.5...@standardnotes/encryption@1.7.6) (2022-05-17) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.7.5](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.7.4...@standardnotes/encryption@1.7.5) (2022-05-16) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.7.4](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.7.3...@standardnotes/encryption@1.7.4) (2022-05-16) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.7.3](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.7.2...@standardnotes/encryption@1.7.3) (2022-05-16) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.7.2](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.7.1...@standardnotes/encryption@1.7.2) (2022-05-13) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.7.1](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.7.0...@standardnotes/encryption@1.7.1) (2022-05-12) + +**Note:** Version bump only for package @standardnotes/encryption + +# [1.7.0](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.6.12...@standardnotes/encryption@1.7.0) (2022-05-12) + +### Features + +* file desktop backups ([#731](https://github.com/standardnotes/snjs/issues/731)) ([0dbce7d](https://github.com/standardnotes/snjs/commit/0dbce7dc9712fde848445b951079c81479c8bc11)) + +## [1.6.12](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.6.11...@standardnotes/encryption@1.6.12) (2022-05-12) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.6.11](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.6.10...@standardnotes/encryption@1.6.11) (2022-05-12) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.6.10](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.6.9...@standardnotes/encryption@1.6.10) (2022-05-09) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.6.9](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.6.8...@standardnotes/encryption@1.6.9) (2022-05-09) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.6.8](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.6.7...@standardnotes/encryption@1.6.8) (2022-05-09) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.6.7](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.6.6...@standardnotes/encryption@1.6.7) (2022-05-06) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.6.6](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.6.5...@standardnotes/encryption@1.6.6) (2022-05-06) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.6.5](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.6.4...@standardnotes/encryption@1.6.5) (2022-05-05) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.6.4](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.6.2...@standardnotes/encryption@1.6.4) (2022-05-04) + +### Bug Fixes + +* config package missing dependencies ([3dec12f](https://github.com/standardnotes/snjs/commit/3dec12fa4a83a8aed8419819eafb7c34795cb09f)) + +## [1.6.3](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.6.2...@standardnotes/encryption@1.6.3) (2022-05-04) + +### Bug Fixes + +* config package missing dependencies ([3dec12f](https://github.com/standardnotes/snjs/commit/3dec12fa4a83a8aed8419819eafb7c34795cb09f)) + +## [1.6.2](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.6.1...@standardnotes/encryption@1.6.2) (2022-05-03) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.6.1](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.6.0...@standardnotes/encryption@1.6.1) (2022-05-02) + +**Note:** Version bump only for package @standardnotes/encryption + +# [1.6.0](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.5.2...@standardnotes/encryption@1.6.0) (2022-04-29) + +### Features + +* service diagnostics ([#718](https://github.com/standardnotes/snjs/issues/718)) ([17cf40f](https://github.com/standardnotes/snjs/commit/17cf40f4489c8f1915b19c0318d252cf83bc050d)) + +## [1.5.2](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.5.1...@standardnotes/encryption@1.5.2) (2022-04-28) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.5.1](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.5.0...@standardnotes/encryption@1.5.1) (2022-04-28) + +**Note:** Version bump only for package @standardnotes/encryption + +# [1.5.0](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.4.13...@standardnotes/encryption@1.5.0) (2022-04-28) + +### Features + +* refactor sncrypto to add unified sha256 and base64 usage ([#715](https://github.com/standardnotes/snjs/issues/715)) ([93aef4d](https://github.com/standardnotes/snjs/commit/93aef4d39228a63f01aa90a88e5d28c3375ed707)) + +## [1.4.13](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.4.12...@standardnotes/encryption@1.4.13) (2022-04-27) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.4.12](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.4.11...@standardnotes/encryption@1.4.12) (2022-04-27) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.4.11](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.4.10...@standardnotes/encryption@1.4.11) (2022-04-27) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.4.10](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.4.9...@standardnotes/encryption@1.4.10) (2022-04-25) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.4.9](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.4.8...@standardnotes/encryption@1.4.9) (2022-04-22) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.4.8](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.4.7...@standardnotes/encryption@1.4.8) (2022-04-22) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.4.7](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.4.6...@standardnotes/encryption@1.4.7) (2022-04-21) + +### Bug Fixes + +* abort key recovery after aborted challenge ([#703](https://github.com/standardnotes/snjs/issues/703)) ([a67fb7e](https://github.com/standardnotes/snjs/commit/a67fb7e8cde41a5c9fadf545933e35d525faeaf0)) + +## [1.4.6](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.4.5...@standardnotes/encryption@1.4.6) (2022-04-20) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.4.5](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.4.4...@standardnotes/encryption@1.4.5) (2022-04-20) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.4.4](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.4.3...@standardnotes/encryption@1.4.4) (2022-04-19) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.4.3](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.4.2...@standardnotes/encryption@1.4.3) (2022-04-19) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.4.2](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.4.1...@standardnotes/encryption@1.4.2) (2022-04-19) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.4.1](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.4.0...@standardnotes/encryption@1.4.1) (2022-04-18) + +### Bug Fixes + +* make timestamps required in payload construction ([#695](https://github.com/standardnotes/snjs/issues/695)) ([b3326c0](https://github.com/standardnotes/snjs/commit/b3326c0a998cd9d44a76afc377f182885ef48275)) + +# [1.4.0](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.3.0...@standardnotes/encryption@1.4.0) (2022-04-15) + +### Features + +* introduce sync resolved payloads to ensure deltas always return up to date dirty state ([#694](https://github.com/standardnotes/snjs/issues/694)) ([e5278ba](https://github.com/standardnotes/snjs/commit/e5278ba0b2afa987c37f009a2101fb91949d44c6)) + +# [1.3.0](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.2.6...@standardnotes/encryption@1.3.0) (2022-04-15) + +### Features + +* no merge payloads in payload manager ([#693](https://github.com/standardnotes/snjs/issues/693)) ([68a577c](https://github.com/standardnotes/snjs/commit/68a577cb887fd2d5556dc9ddec461f6ae665fcb6)) + +## [1.2.6](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.2.5...@standardnotes/encryption@1.2.6) (2022-04-15) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.2.5](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.2.4...@standardnotes/encryption@1.2.5) (2022-04-14) + +### Bug Fixes + +* map ignored item timestamps so application remains in sync ([#692](https://github.com/standardnotes/snjs/issues/692)) ([966cbb0](https://github.com/standardnotes/snjs/commit/966cbb0c254d4d95c802bd8951488a499d1f8bef)) + +## [1.2.4](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.2.3...@standardnotes/encryption@1.2.4) (2022-04-13) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.2.3](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.2.2...@standardnotes/encryption@1.2.3) (2022-04-12) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.2.2](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.2.1...@standardnotes/encryption@1.2.2) (2022-04-11) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.2.1](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.2.0...@standardnotes/encryption@1.2.1) (2022-04-01) + +**Note:** Version bump only for package @standardnotes/encryption + +# [1.2.0](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.1.4...@standardnotes/encryption@1.2.0) (2022-04-01) + +### Features + +* content interfaces and model type strictness ([#685](https://github.com/standardnotes/snjs/issues/685)) ([e2450c5](https://github.com/standardnotes/snjs/commit/e2450c59e8309d7080efaa03905b2abc728d9403)) + +## [1.1.4](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.1.3...@standardnotes/encryption@1.1.4) (2022-04-01) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.1.3](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.1.2...@standardnotes/encryption@1.1.3) (2022-03-31) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.1.2](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.1.1...@standardnotes/encryption@1.1.2) (2022-03-31) + +**Note:** Version bump only for package @standardnotes/encryption + +## [1.1.1](https://github.com/standardnotes/snjs/compare/@standardnotes/encryption@1.1.0...@standardnotes/encryption@1.1.1) (2022-03-31) + +### Bug Fixes + +* return undefined instead of exception when items key is not yet available ([#682](https://github.com/standardnotes/snjs/issues/682)) ([0dd7dbe](https://github.com/standardnotes/snjs/commit/0dd7dbe64c086bbdd539f24d428ac1d2bb491f16)) + +# 1.1.0 (2022-03-31) + +### Features + +* encryption and models packages ([#679](https://github.com/standardnotes/snjs/issues/679)) ([5e03d48](https://github.com/standardnotes/snjs/commit/5e03d48aba7e3dd266117201139ab869b1f70cc9)) diff --git a/packages/encryption/jest.config.js b/packages/encryption/jest.config.js new file mode 100644 index 000000000..ad1ceabb0 --- /dev/null +++ b/packages/encryption/jest.config.js @@ -0,0 +1,11 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const base = require('../../node_modules/@standardnotes/config/src/jest.json'); + +module.exports = { + ...base, + globals: { + 'ts-jest': { + tsconfig: 'tsconfig.json', + }, + } +}; diff --git a/packages/encryption/linter.tsconfig.json b/packages/encryption/linter.tsconfig.json new file mode 100644 index 000000000..c1a7d22c5 --- /dev/null +++ b/packages/encryption/linter.tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["dist"] +} diff --git a/packages/encryption/package.json b/packages/encryption/package.json new file mode 100644 index 000000000..7ed0ae3de --- /dev/null +++ b/packages/encryption/package.json @@ -0,0 +1,45 @@ +{ + "name": "@standardnotes/encryption", + "version": "1.9.0", + "engines": { + "node": ">=16.0.0 <17.0.0" + }, + "description": "Payload encryption used in SNJS library", + "main": "dist/index.js", + "author": "Standard Notes", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "license": "AGPL-3.0-or-later", + "scripts": { + "clean": "rm -fr dist", + "prestart": "yarn clean", + "start": "tsc -p tsconfig.json --watch", + "prebuild": "yarn clean", + "build": "tsc -p tsconfig.json", + "lint": "eslint . --ext .ts", + "test": "jest" + }, + "devDependencies": { + "@standardnotes/config": "2.4.3", + "@types/jest": "^27.4.1", + "@types/node": "^18.0.0", + "@typescript-eslint/eslint-plugin": "^5.30.0", + "eslint-plugin-prettier": "^4.2.1", + "jest": "^27.5.1", + "ts-jest": "^27.1.3" + }, + "dependencies": { + "@standardnotes/common": "^1.23.1", + "@standardnotes/models": "^1.11.13", + "@standardnotes/responses": "^1.6.39", + "@standardnotes/services": "^1.13.23", + "@standardnotes/sncrypto-common": "^1.9.0", + "@standardnotes/utils": "^1.6.12", + "reflect-metadata": "^0.1.13" + } +} diff --git a/packages/encryption/src/Domain/Algorithm.ts b/packages/encryption/src/Domain/Algorithm.ts new file mode 100644 index 000000000..e9875bb04 --- /dev/null +++ b/packages/encryption/src/Domain/Algorithm.ts @@ -0,0 +1,46 @@ +export const V001Algorithm = Object.freeze({ + SaltSeedLength: 128, + /** + * V001 supported a variable PBKDF2 cost + */ + PbkdfMinCost: 3000, + PbkdfCostsUsed: [3000, 5000, 10_000, 60_000], + PbkdfOutputLength: 512, + EncryptionKeyLength: 256, +}) + +export const V002Algorithm = Object.freeze({ + SaltSeedLength: 128, + /** + * V002 supported a variable PBKDF2 cost + */ + PbkdfMinCost: 3000, + /** + * While some 002 accounts also used costs in V001.PbkdfCostsUsed, + * the vast majority used costs >= 100,000 + */ + PbkdfCostsUsed: V001Algorithm.PbkdfCostsUsed.concat([100_000, 101_000, 102_000, 103_000]), + /** Possible costs used, but statistically more likely these were 001 accounts */ + ImprobablePbkdfCostsUsed: [3000, 5000], + PbkdfOutputLength: 768, + EncryptionKeyLength: 256, + EncryptionIvLength: 128, +}) + +export enum V003Algorithm { + SaltSeedLength = 256, + PbkdfCost = 110000, + PbkdfOutputLength = 768, + EncryptionKeyLength = 256, + EncryptionIvLength = 128, +} + +export enum V004Algorithm { + ArgonSaltSeedLength = 256, + ArgonSaltLength = 128, + ArgonIterations = 5, + ArgonMemLimit = 67108864, + ArgonOutputKeyBytes = 64, + EncryptionKeyLength = 256, + EncryptionNonceLength = 192, +} diff --git a/packages/encryption/src/Domain/Backups/BackupFile.ts b/packages/encryption/src/Domain/Backups/BackupFile.ts new file mode 100644 index 000000000..5ce9e9539 --- /dev/null +++ b/packages/encryption/src/Domain/Backups/BackupFile.ts @@ -0,0 +1,23 @@ +import { BackupFileDecryptedContextualPayload, BackupFileEncryptedContextualPayload } from '@standardnotes/models' +import { AnyKeyParamsContent, ProtocolVersion } from '@standardnotes/common' + +export type BackupFile = { + version?: ProtocolVersion + keyParams?: AnyKeyParamsContent + auth_params?: AnyKeyParamsContent + items: (BackupFileDecryptedContextualPayload | BackupFileEncryptedContextualPayload)[] +} + +export enum BackupFileType { + Encrypted = 'Encrypted', + + /** + * Generated when an export is made from an application with no account and no passcode. The + * items are encrypted, but the items keys are not. + */ + EncryptedWithNonEncryptedItemsKey = 'EncryptedWithNonEncryptedItemsKey', + + FullyDecrypted = 'FullyDecrypted', + + Corrupt = 'Corrupt', +} diff --git a/packages/encryption/src/Domain/Backups/BackupFileDecryptor.ts b/packages/encryption/src/Domain/Backups/BackupFileDecryptor.ts new file mode 100644 index 000000000..095e2786b --- /dev/null +++ b/packages/encryption/src/Domain/Backups/BackupFileDecryptor.ts @@ -0,0 +1,247 @@ +import { + AnyKeyParamsContent, + ContentType, + ProtocolVersion, + leftVersionGreaterThanOrEqualToRight, + compareVersions, +} from '@standardnotes/common' +import { BackupFile, BackupFileType } from './BackupFile' +import { extendArray } from '@standardnotes/utils' +import { EncryptionService } from '../Service/Encryption/EncryptionService' +import { + PayloadInterface, + DecryptedPayloadInterface, + ItemsKeyContent, + EncryptedPayloadInterface, + isEncryptedPayload, + isDecryptedPayload, + isEncryptedTransferPayload, + EncryptedPayload, + DecryptedPayload, + isDecryptedTransferPayload, + CreateDecryptedItemFromPayload, + ItemsKeyInterface, + CreatePayloadSplit, +} from '@standardnotes/models' +import { ClientDisplayableError } from '@standardnotes/responses' +import { CreateAnyKeyParams } from '../Keys/RootKey/KeyParamsFunctions' +import { SNRootKeyParams } from '../Keys/RootKey/RootKeyParams' +import { SNRootKey } from '../Keys/RootKey/RootKey' +import { ContentTypeUsesRootKeyEncryption } from '../Keys/RootKey/Functions' +import { isItemsKey, SNItemsKey } from '../Keys/ItemsKey' + +export async function DecryptBackupFile( + file: BackupFile, + protocolService: EncryptionService, + password?: string, +): Promise { + const payloads: (EncryptedPayloadInterface | DecryptedPayloadInterface)[] = file.items.map((item) => { + if (isEncryptedTransferPayload(item)) { + return new EncryptedPayload(item) + } else if (isDecryptedTransferPayload(item)) { + return new DecryptedPayload(item) + } else { + throw Error('Unhandled case in decryptBackupFile') + } + }) + + const { encrypted, decrypted } = CreatePayloadSplit(payloads) + + const type = getBackupFileType(file, payloads) + + switch (type) { + case BackupFileType.Corrupt: + return new ClientDisplayableError('Invalid backup file.') + case BackupFileType.Encrypted: { + if (!password) { + throw Error('Attempting to decrypt encrypted file with no password') + } + + const keyParamsData = (file.keyParams || file.auth_params) as AnyKeyParamsContent + + return [ + ...decrypted, + ...(await decryptEncrypted(password, CreateAnyKeyParams(keyParamsData), encrypted, protocolService)), + ] + } + case BackupFileType.EncryptedWithNonEncryptedItemsKey: + return [...decrypted, ...(await decryptEncryptedWithNonEncryptedItemsKey(payloads, protocolService))] + case BackupFileType.FullyDecrypted: + return [...decrypted, ...encrypted] + } +} + +function getBackupFileType(file: BackupFile, payloads: PayloadInterface[]): BackupFileType { + if (file.keyParams || file.auth_params) { + return BackupFileType.Encrypted + } else { + const hasEncryptedItem = payloads.find(isEncryptedPayload) + const hasDecryptedItemsKey = payloads.find( + (payload) => payload.content_type === ContentType.ItemsKey && isDecryptedPayload(payload), + ) + + if (hasEncryptedItem && hasDecryptedItemsKey) { + return BackupFileType.EncryptedWithNonEncryptedItemsKey + } else if (!hasEncryptedItem) { + return BackupFileType.FullyDecrypted + } else { + return BackupFileType.Corrupt + } + } +} + +async function decryptEncryptedWithNonEncryptedItemsKey( + allPayloads: (EncryptedPayloadInterface | DecryptedPayloadInterface)[], + protocolService: EncryptionService, +): Promise<(EncryptedPayloadInterface | DecryptedPayloadInterface)[]> { + const decryptedItemsKeys: DecryptedPayloadInterface[] = [] + const encryptedPayloads: EncryptedPayloadInterface[] = [] + + allPayloads.forEach((payload) => { + if (payload.content_type === ContentType.ItemsKey && isDecryptedPayload(payload)) { + decryptedItemsKeys.push(payload as DecryptedPayloadInterface) + } else if (isEncryptedPayload(payload)) { + encryptedPayloads.push(payload) + } + }) + + const itemsKeys = decryptedItemsKeys.map((p) => CreateDecryptedItemFromPayload(p)) + + return decryptWithItemsKeys(encryptedPayloads, itemsKeys, protocolService) +} + +function findKeyToUseForPayload( + payload: EncryptedPayloadInterface, + availableKeys: ItemsKeyInterface[], + protocolService: EncryptionService, + keyParams?: SNRootKeyParams, + fallbackRootKey?: SNRootKey, +): ItemsKeyInterface | SNRootKey | undefined { + let itemsKey: ItemsKeyInterface | SNRootKey | undefined + + if (payload.items_key_id) { + itemsKey = protocolService.itemsKeyForPayload(payload) + if (itemsKey) { + return itemsKey + } + } + + itemsKey = availableKeys.find((itemsKeyPayload) => { + return payload.items_key_id === itemsKeyPayload.uuid + }) + + if (itemsKey) { + return itemsKey + } + + if (!keyParams) { + return undefined + } + + const payloadVersion = payload.version as ProtocolVersion + + /** + * Payloads with versions <= 003 use root key directly for encryption. + * However, if the incoming key params are >= 004, this means we should + * have an items key based off the 003 root key. We can't use the 004 + * root key directly because it's missing dataAuthenticationKey. + */ + if (leftVersionGreaterThanOrEqualToRight(keyParams.version, ProtocolVersion.V004)) { + itemsKey = protocolService.defaultItemsKeyForItemVersion(payloadVersion, availableKeys) + } else if (compareVersions(payloadVersion, ProtocolVersion.V003) <= 0) { + itemsKey = fallbackRootKey + } + + return itemsKey +} + +async function decryptWithItemsKeys( + payloads: EncryptedPayloadInterface[], + itemsKeys: ItemsKeyInterface[], + protocolService: EncryptionService, + keyParams?: SNRootKeyParams, + fallbackRootKey?: SNRootKey, +): Promise<(DecryptedPayloadInterface | EncryptedPayloadInterface)[]> { + const results: (DecryptedPayloadInterface | EncryptedPayloadInterface)[] = [] + + for (const encryptedPayload of payloads) { + if (ContentTypeUsesRootKeyEncryption(encryptedPayload.content_type)) { + continue + } + + try { + const key = findKeyToUseForPayload(encryptedPayload, itemsKeys, protocolService, keyParams, fallbackRootKey) + + if (!key) { + results.push( + encryptedPayload.copy({ + errorDecrypting: true, + }), + ) + continue + } + + if (isItemsKey(key)) { + const decryptedPayload = await protocolService.decryptSplitSingle({ + usesItemsKey: { + items: [encryptedPayload], + key: key, + }, + }) + results.push(decryptedPayload) + } else { + const decryptedPayload = await protocolService.decryptSplitSingle({ + usesRootKey: { + items: [encryptedPayload], + key: key, + }, + }) + results.push(decryptedPayload) + } + } catch (e) { + results.push( + encryptedPayload.copy({ + errorDecrypting: true, + }), + ) + console.error('Error decrypting payload', encryptedPayload, e) + } + } + + return results +} + +async function decryptEncrypted( + password: string, + keyParams: SNRootKeyParams, + payloads: EncryptedPayloadInterface[], + protocolService: EncryptionService, +): Promise<(EncryptedPayloadInterface | DecryptedPayloadInterface)[]> { + const results: (EncryptedPayloadInterface | DecryptedPayloadInterface)[] = [] + const rootKey = await protocolService.computeRootKey(password, keyParams) + + const itemsKeysPayloads = payloads.filter((payload) => { + return payload.content_type === ContentType.ItemsKey + }) + + const itemsKeysDecryptionResults = await protocolService.decryptSplit({ + usesRootKey: { + items: itemsKeysPayloads, + key: rootKey, + }, + }) + + extendArray(results, itemsKeysDecryptionResults) + + const decryptedPayloads = await decryptWithItemsKeys( + payloads, + itemsKeysDecryptionResults.filter(isDecryptedPayload).map((p) => CreateDecryptedItemFromPayload(p)), + protocolService, + keyParams, + rootKey, + ) + + extendArray(results, decryptedPayloads) + + return results +} diff --git a/packages/encryption/src/Domain/Keys/ItemsKey/ItemsKey.ts b/packages/encryption/src/Domain/Keys/ItemsKey/ItemsKey.ts new file mode 100644 index 000000000..ada2003ae --- /dev/null +++ b/packages/encryption/src/Domain/Keys/ItemsKey/ItemsKey.ts @@ -0,0 +1,46 @@ +import { + ConflictStrategy, + ItemsKeyContent, + DecryptedItem, + DecryptedPayloadInterface, + DecryptedItemInterface, + HistoryEntryInterface, + ItemsKeyInterface, + RootKeyInterface, +} from '@standardnotes/models' +import { ContentType, ProtocolVersion } from '@standardnotes/common' + +export function isItemsKey(x: ItemsKeyInterface | RootKeyInterface): x is ItemsKeyInterface { + return x.content_type === ContentType.ItemsKey +} + +/** + * A key used to encrypt other items. Items keys are synced and persisted. + */ +export class SNItemsKey extends DecryptedItem implements ItemsKeyInterface { + keyVersion: ProtocolVersion + isDefault: boolean | undefined + itemsKey: string + + constructor(payload: DecryptedPayloadInterface) { + super(payload) + this.keyVersion = payload.content.version + this.isDefault = payload.content.isDefault + this.itemsKey = this.payload.content.itemsKey + } + + /** Do not duplicate items keys. Always keep original */ + override strategyWhenConflictingWithItem( + _item: DecryptedItemInterface, + _previousRevision?: HistoryEntryInterface, + ): ConflictStrategy { + return ConflictStrategy.KeepBase + } + + get dataAuthenticationKey(): string | undefined { + if (this.keyVersion === ProtocolVersion.V004) { + throw 'Attempting to access legacy data authentication key.' + } + return this.payload.content.dataAuthenticationKey + } +} diff --git a/packages/encryption/src/Domain/Keys/ItemsKey/ItemsKeyMutator.ts b/packages/encryption/src/Domain/Keys/ItemsKey/ItemsKeyMutator.ts new file mode 100644 index 000000000..817022271 --- /dev/null +++ b/packages/encryption/src/Domain/Keys/ItemsKey/ItemsKeyMutator.ts @@ -0,0 +1,7 @@ +import { DecryptedItemMutator, ItemsKeyMutatorInterface, ItemsKeyContent } from '@standardnotes/models' + +export class ItemsKeyMutator extends DecryptedItemMutator implements ItemsKeyMutatorInterface { + set isDefault(isDefault: boolean) { + this.mutableContent.isDefault = isDefault + } +} diff --git a/packages/encryption/src/Domain/Keys/ItemsKey/Registration.ts b/packages/encryption/src/Domain/Keys/ItemsKey/Registration.ts new file mode 100644 index 000000000..1c78456fc --- /dev/null +++ b/packages/encryption/src/Domain/Keys/ItemsKey/Registration.ts @@ -0,0 +1,6 @@ +import { ContentType } from '@standardnotes/common' +import { RegisterItemClass, DecryptedItemMutator, ItemsKeyContent } from '@standardnotes/models' +import { SNItemsKey } from './ItemsKey' +import { ItemsKeyMutator } from './ItemsKeyMutator' + +RegisterItemClass(ContentType.ItemsKey, SNItemsKey, ItemsKeyMutator as unknown as DecryptedItemMutator) diff --git a/packages/encryption/src/Domain/Keys/ItemsKey/index.ts b/packages/encryption/src/Domain/Keys/ItemsKey/index.ts new file mode 100644 index 000000000..05b0b381c --- /dev/null +++ b/packages/encryption/src/Domain/Keys/ItemsKey/index.ts @@ -0,0 +1,3 @@ +export * from './ItemsKey' +export * from './ItemsKeyMutator' +export * from './Registration' diff --git a/packages/encryption/src/Domain/Keys/RootKey/Functions.ts b/packages/encryption/src/Domain/Keys/RootKey/Functions.ts new file mode 100644 index 000000000..284d27e25 --- /dev/null +++ b/packages/encryption/src/Domain/Keys/RootKey/Functions.ts @@ -0,0 +1,51 @@ +import { SNRootKey } from './RootKey' +import { + DecryptedPayload, + FillItemContentSpecialized, + PayloadTimestampDefaults, + RootKeyContent, + RootKeyContentSpecialized, +} from '@standardnotes/models' +import { UuidGenerator } from '@standardnotes/utils' +import { ContentType, ProtocolVersion } from '@standardnotes/common' + +export function CreateNewRootKey(content: RootKeyContentSpecialized): SNRootKey { + const uuid = UuidGenerator.GenerateUuid() + + const payload = new DecryptedPayload({ + uuid: uuid, + content_type: ContentType.RootKey, + content: FillRootKeyContent(content), + ...PayloadTimestampDefaults(), + }) + + return new SNRootKey(payload) +} + +export function FillRootKeyContent(content: Partial): RootKeyContent { + if (!content.version) { + if (content.dataAuthenticationKey) { + /** + * If there's no version stored, it must be either 001 or 002. + * If there's a dataAuthenticationKey, it has to be 002. Otherwise it's 001. + */ + content.version = ProtocolVersion.V002 + } else { + content.version = ProtocolVersion.V001 + } + } + + return FillItemContentSpecialized(content) +} + +export function ContentTypeUsesRootKeyEncryption(contentType: ContentType): boolean { + return ( + contentType === ContentType.RootKey || + contentType === ContentType.ItemsKey || + contentType === ContentType.EncryptedStorage + ) +} + +export function ItemContentTypeUsesRootKeyEncryption(contentType: ContentType): boolean { + return contentType === ContentType.ItemsKey +} diff --git a/packages/encryption/src/Domain/Keys/RootKey/KeyParamsFunctions.ts b/packages/encryption/src/Domain/Keys/RootKey/KeyParamsFunctions.ts new file mode 100644 index 000000000..69df68cf1 --- /dev/null +++ b/packages/encryption/src/Domain/Keys/RootKey/KeyParamsFunctions.ts @@ -0,0 +1,60 @@ +import { KeyParamsResponse } from '@standardnotes/responses' +import { + KeyParamsContent001, + KeyParamsContent002, + KeyParamsContent003, + KeyParamsContent004, + AnyKeyParamsContent, +} from '@standardnotes/common' +import { SNRootKeyParams } from './RootKeyParams' +import { ProtocolVersionForKeyParams } from './ProtocolVersionForKeyParams' + +/** + * 001, 002: + * - Nonce is not uploaded to server, instead used to compute salt locally and send to server + * - Salt is returned from server + * - Cost/iteration count is returned from the server + * - Account identifier is returned as 'email' + * 003, 004: + * - Salt is computed locally via the seed (pw_nonce) returned from the server + * - Cost/iteration count is determined locally by the protocol version + * - Account identifier is returned as 'identifier' + */ + +export type AllKeyParamsContents = KeyParamsContent001 & KeyParamsContent002 & KeyParamsContent003 & KeyParamsContent004 + +export function Create001KeyParams(keyParams: KeyParamsContent001) { + return CreateAnyKeyParams(keyParams) +} + +export function Create002KeyParams(keyParams: KeyParamsContent002) { + return CreateAnyKeyParams(keyParams) +} + +export function Create003KeyParams(keyParams: KeyParamsContent003) { + return CreateAnyKeyParams(keyParams) +} + +export function Create004KeyParams(keyParams: KeyParamsContent004) { + return CreateAnyKeyParams(keyParams) +} + +export function CreateAnyKeyParams(keyParams: AnyKeyParamsContent) { + if ('content' in keyParams) { + throw Error('Raw key params shouldnt have content; perhaps you passed in a SNRootKeyParams object.') + } + return new SNRootKeyParams(keyParams) +} + +export function KeyParamsFromApiResponse(response: KeyParamsResponse, identifier?: string) { + const rawKeyParams: AnyKeyParamsContent = { + identifier: identifier || response.data.identifier!, + pw_cost: response.data.pw_cost!, + pw_nonce: response.data.pw_nonce!, + pw_salt: response.data.pw_salt!, + version: ProtocolVersionForKeyParams(response.data), + origination: response.data.origination, + created: response.data.created, + } + return CreateAnyKeyParams(rawKeyParams) +} diff --git a/packages/encryption/src/Domain/Keys/RootKey/ProtocolVersionForKeyParams.ts b/packages/encryption/src/Domain/Keys/RootKey/ProtocolVersionForKeyParams.ts new file mode 100644 index 000000000..df25d1502 --- /dev/null +++ b/packages/encryption/src/Domain/Keys/RootKey/ProtocolVersionForKeyParams.ts @@ -0,0 +1,49 @@ +import { V001Algorithm, V002Algorithm } from '../../Algorithm' +import { KeyParamsData } from '@standardnotes/responses' +import { AnyKeyParamsContent, ProtocolVersion } from '@standardnotes/common' + +export function ProtocolVersionForKeyParams(response: KeyParamsData | AnyKeyParamsContent): ProtocolVersion { + if (response.version) { + return response.version + } + /** + * 001 and 002 key params (as stored locally) may not report a version number. + * In some cases it may be impossible to differentiate between 001 and 002 params, + * but there are a few rules we can use to find a best fit. + */ + /** + * First try to determine by cost. If the cost appears in V002 costs but not V001 costs, + * we know it's 002. + */ + const cost = response.pw_cost! + const appearsInV001 = V001Algorithm.PbkdfCostsUsed.includes(cost) + const appearsInV002 = V002Algorithm.PbkdfCostsUsed.includes(cost) + + if (appearsInV001 && !appearsInV002) { + return ProtocolVersion.V001 + } else if (appearsInV002 && !appearsInV001) { + return ProtocolVersion.V002 + } else if (appearsInV002 && appearsInV001) { + /** + * If the cost appears in both versions, we can be certain it's 002 if it's missing + * the pw_nonce property. (However late versions of 002 also used a pw_nonce, so its + * presence doesn't automatically indicate 001.) + */ + if (!response.pw_nonce) { + return ProtocolVersion.V002 + } else { + /** + * We're now at the point that the cost has appeared in both versions, and a pw_nonce + * is present. We'll have to go with what is more statistically likely. + */ + if (V002Algorithm.ImprobablePbkdfCostsUsed.includes(cost)) { + return ProtocolVersion.V001 + } else { + return ProtocolVersion.V002 + } + } + } else { + /** Doesn't appear in either V001 or V002; unlikely possibility. */ + return ProtocolVersion.V002 + } +} diff --git a/packages/encryption/src/Domain/Keys/RootKey/RootKey.ts b/packages/encryption/src/Domain/Keys/RootKey/RootKey.ts new file mode 100644 index 000000000..63b99e565 --- /dev/null +++ b/packages/encryption/src/Domain/Keys/RootKey/RootKey.ts @@ -0,0 +1,95 @@ +import { SNRootKeyParams } from './RootKeyParams' +import { + RootKeyInterface, + RootKeyContent, + DecryptedItem, + DecryptedPayloadInterface, + RootKeyContentInStorage, + NamespacedRootKeyInKeychain, +} from '@standardnotes/models' +import { ProtocolVersion } from '@standardnotes/common' +import { timingSafeEqual } from '@standardnotes/sncrypto-common' + +/** + * A root key is a local only construct that houses the key used for the encryption + * and decryption of items keys. A root key extends SNItem for local convenience, but is + * not part of the syncing or storage ecosystem—root keys are managed independently. + */ +export class SNRootKey extends DecryptedItem implements RootKeyInterface { + public readonly keyParams: SNRootKeyParams + + constructor(payload: DecryptedPayloadInterface) { + super(payload) + + this.keyParams = new SNRootKeyParams(payload.content.keyParams) + } + + public get keyVersion(): ProtocolVersion { + return this.content.version + } + + /** + * When the root key is used to encrypt items, we use the masterKey directly. + */ + public get itemsKey(): string { + return this.masterKey + } + + public get masterKey(): string { + return this.content.masterKey + } + + /** + * serverPassword is not persisted as part of keychainValue, so if loaded from disk, + * this value may be undefined. + */ + public get serverPassword(): string | undefined { + return this.content.serverPassword + } + + /** 003 and below only. */ + public get dataAuthenticationKey(): string | undefined { + return this.content.dataAuthenticationKey + } + + public compare(otherKey: SNRootKey): boolean { + if (this.keyVersion !== otherKey.keyVersion) { + return false + } + + if (this.serverPassword && otherKey.serverPassword) { + return ( + timingSafeEqual(this.masterKey, otherKey.masterKey) && + timingSafeEqual(this.serverPassword, otherKey.serverPassword) + ) + } else { + return timingSafeEqual(this.masterKey, otherKey.masterKey) + } + } + + /** + * @returns Object suitable for persist in storage when wrapped + */ + public persistableValueWhenWrapping(): RootKeyContentInStorage { + return { + ...this.getKeychainValue(), + keyParams: this.keyParams.getPortableValue(), + } + } + + /** + * @returns Object that is suitable for persisting in a keychain + */ + public getKeychainValue(): NamespacedRootKeyInKeychain { + const values: NamespacedRootKeyInKeychain = { + version: this.keyVersion, + masterKey: this.masterKey, + } + + if (this.dataAuthenticationKey) { + values.dataAuthenticationKey = this.dataAuthenticationKey + } + + return values + } +} diff --git a/packages/encryption/src/Domain/Keys/RootKey/RootKeyParams.ts b/packages/encryption/src/Domain/Keys/RootKey/RootKeyParams.ts new file mode 100644 index 000000000..6d31a7179 --- /dev/null +++ b/packages/encryption/src/Domain/Keys/RootKey/RootKeyParams.ts @@ -0,0 +1,82 @@ +import { + KeyParamsContent001, + KeyParamsContent002, + KeyParamsContent003, + KeyParamsContent004, + AnyKeyParamsContent, + ProtocolVersion, + KeyParamsOrigination, +} from '@standardnotes/common' +import { RootKeyParamsInterface } from '@standardnotes/models' +import { pickByCopy } from '@standardnotes/utils' +import { ProtocolVersionForKeyParams } from './ProtocolVersionForKeyParams' +import { ValidKeyParamsKeys } from './ValidKeyParamsKeys' + +export class SNRootKeyParams implements RootKeyParamsInterface { + public readonly content: AnyKeyParamsContent + + constructor(content: AnyKeyParamsContent) { + this.content = { + ...content, + origination: content.origination || KeyParamsOrigination.Registration, + version: content.version || ProtocolVersionForKeyParams(content), + } + } + + get isKeyParamsObject(): boolean { + return true + } + + get identifier(): string { + return this.content004.identifier || this.content002.email + } + + get version(): ProtocolVersion { + return this.content.version + } + + get origination(): KeyParamsOrigination | undefined { + return this.content.origination + } + + get content001(): KeyParamsContent001 { + return this.content as KeyParamsContent001 + } + + get content002(): KeyParamsContent002 { + return this.content as KeyParamsContent002 + } + + get content003(): KeyParamsContent003 { + return this.content as KeyParamsContent003 + } + + get content004(): KeyParamsContent004 { + return this.content as KeyParamsContent004 + } + + get createdDate(): Date | undefined { + if (!this.content004.created) { + return undefined + } + return new Date(Number(this.content004.created)) + } + + compare(other: SNRootKeyParams): boolean { + if (this.version !== other.version) { + return false + } + + if ([ProtocolVersion.V004, ProtocolVersion.V003].includes(this.version)) { + return this.identifier === other.identifier && this.content004.pw_nonce === other.content003.pw_nonce + } else if ([ProtocolVersion.V002, ProtocolVersion.V001].includes(this.version)) { + return this.identifier === other.identifier && this.content002.pw_salt === other.content001.pw_salt + } else { + throw Error('Unhandled version in KeyParams.compare') + } + } + + getPortableValue(): AnyKeyParamsContent { + return pickByCopy(this.content, ValidKeyParamsKeys as (keyof AnyKeyParamsContent)[]) + } +} diff --git a/packages/encryption/src/Domain/Keys/RootKey/ValidKeyParamsKeys.ts b/packages/encryption/src/Domain/Keys/RootKey/ValidKeyParamsKeys.ts new file mode 100644 index 000000000..28093850f --- /dev/null +++ b/packages/encryption/src/Domain/Keys/RootKey/ValidKeyParamsKeys.ts @@ -0,0 +1,11 @@ +import { AllKeyParamsContents } from './KeyParamsFunctions' + +export const ValidKeyParamsKeys: (keyof AllKeyParamsContents)[] = [ + 'identifier', + 'pw_cost', + 'pw_nonce', + 'pw_salt', + 'version', + 'origination', + 'created', +] diff --git a/packages/encryption/src/Domain/Keys/Utils/DecryptItemsKey.ts b/packages/encryption/src/Domain/Keys/Utils/DecryptItemsKey.ts new file mode 100644 index 000000000..a8f5f2602 --- /dev/null +++ b/packages/encryption/src/Domain/Keys/Utils/DecryptItemsKey.ts @@ -0,0 +1,95 @@ +import { + DecryptedPayloadInterface, + EncryptedPayloadInterface, + isDecryptedPayload, + ItemsKeyContent, + RootKeyInterface, +} from '@standardnotes/models' +import { + ChallengePrompt, + ChallengeReason, + ChallengeServiceInterface, + ChallengeValidation, +} from '@standardnotes/services' +import { EncryptionProvider } from '../../Service/Encryption/EncryptionProvider' +import { SNRootKeyParams } from '../RootKey/RootKeyParams' +import { KeyRecoveryStrings } from './KeyRecoveryStrings' + +export async function DecryptItemsKeyWithUserFallback( + itemsKey: EncryptedPayloadInterface, + encryptor: EncryptionProvider, + challengor: ChallengeServiceInterface, +): Promise | 'failed' | 'aborted'> { + const decryptionResult = await encryptor.decryptSplitSingle({ + usesRootKeyWithKeyLookup: { + items: [itemsKey], + }, + }) + + if (isDecryptedPayload(decryptionResult)) { + return decryptionResult + } + + const secondDecryptionResult = await DecryptItemsKeyByPromptingUser(itemsKey, encryptor, challengor) + + if (secondDecryptionResult === 'aborted' || secondDecryptionResult === 'failed') { + return secondDecryptionResult + } + + return secondDecryptionResult.decryptedKey +} + +export async function DecryptItemsKeyByPromptingUser( + itemsKey: EncryptedPayloadInterface, + encryptor: EncryptionProvider, + challengor: ChallengeServiceInterface, + keyParams?: SNRootKeyParams, +): Promise< + | { + decryptedKey: DecryptedPayloadInterface + rootKey: RootKeyInterface + } + | 'failed' + | 'aborted' +> { + if (!keyParams) { + keyParams = encryptor.getKeyEmbeddedKeyParams(itemsKey) + } + + if (!keyParams) { + return 'failed' + } + + const challenge = challengor.createChallenge( + [new ChallengePrompt(ChallengeValidation.None, undefined, undefined, true)], + ChallengeReason.Custom, + true, + KeyRecoveryStrings.KeyRecoveryLoginFlowPrompt(keyParams), + KeyRecoveryStrings.KeyRecoveryPasswordRequired, + ) + + const response = await challengor.promptForChallengeResponse(challenge) + + if (!response) { + return 'aborted' + } + + const password = response.values[0].value as string + + const rootKey = await encryptor.computeRootKey(password, keyParams) + + const secondDecryptionResult = await encryptor.decryptSplitSingle({ + usesRootKey: { + items: [itemsKey], + key: rootKey, + }, + }) + + challengor.completeChallenge(challenge) + + if (isDecryptedPayload(secondDecryptionResult)) { + return { decryptedKey: secondDecryptionResult, rootKey } + } + + return 'failed' +} diff --git a/packages/encryption/src/Domain/Keys/Utils/KeyRecoveryStrings.ts b/packages/encryption/src/Domain/Keys/Utils/KeyRecoveryStrings.ts new file mode 100644 index 000000000..0edac61b7 --- /dev/null +++ b/packages/encryption/src/Domain/Keys/Utils/KeyRecoveryStrings.ts @@ -0,0 +1,32 @@ +import { KeyParamsOrigination } from '@standardnotes/common' +import { SNRootKeyParams } from '../RootKey/RootKeyParams' + +export const KeyRecoveryStrings = { + KeyRecoveryLoginFlowPrompt: (keyParams: SNRootKeyParams) => { + const dateString = keyParams.createdDate?.toLocaleString() + switch (keyParams.origination) { + case KeyParamsOrigination.EmailChange: + return `Enter your account password as it was when you changed your email on ${dateString}.` + case KeyParamsOrigination.PasswordChange: + return `Enter your account password after it was changed on ${dateString}.` + case KeyParamsOrigination.Registration: + return `Enter your account password as it was when you registered ${dateString}.` + case KeyParamsOrigination.ProtocolUpgrade: + return `Enter your account password as it was when you upgraded your encryption version on ${dateString}.` + case KeyParamsOrigination.PasscodeChange: + return `Enter your application passcode after it was changed on ${dateString}.` + case KeyParamsOrigination.PasscodeCreate: + return `Enter your application passcode as it was when you created it on ${dateString}.` + default: + throw Error('Unhandled KeyParamsOrigination case for KeyRecoveryLoginFlowPrompt') + } + }, + KeyRecoveryLoginFlowReason: 'Your account password is required to revalidate your session.', + KeyRecoveryLoginFlowInvalidPassword: 'Incorrect credentials entered. Please try again.', + KeyRecoveryRootKeyReplaced: 'Your credentials have successfully been updated.', + KeyRecoveryPasscodeRequiredTitle: 'Passcode Required', + KeyRecoveryPasscodeRequiredText: 'You must enter your passcode in order to save your new credentials.', + KeyRecoveryPasswordRequired: 'Your account password is required to recover an encryption key.', + KeyRecoveryKeyRecovered: 'Your key has successfully been recovered.', + KeyRecoveryUnableToRecover: 'Unable to recover your key with the attempted password. Please try again.', +} diff --git a/packages/encryption/src/Domain/Operator/001/Operator001.ts b/packages/encryption/src/Domain/Operator/001/Operator001.ts new file mode 100644 index 000000000..8ccdb5284 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/001/Operator001.ts @@ -0,0 +1,215 @@ +import { ContentType, KeyParamsOrigination, ProtocolVersion, ProtocolVersionLength } from '@standardnotes/common' +import { Create001KeyParams } from '../../Keys/RootKey/KeyParamsFunctions' +import { firstHalfOfString, secondHalfOfString, splitString, UuidGenerator } from '@standardnotes/utils' +import { AsynchronousOperator } from '../Operator' +import { + CreateDecryptedItemFromPayload, + ItemsKeyContent, + ItemsKeyInterface, + FillItemContent, + ItemContent, + DecryptedPayload, + DecryptedPayloadInterface, + PayloadTimestampDefaults, +} from '@standardnotes/models' +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { SNRootKey } from '../../Keys/RootKey/RootKey' +import { SNRootKeyParams } from '../../Keys/RootKey/RootKeyParams' +import { V001Algorithm } from '../../Algorithm' +import { DecryptedParameters, EncryptedParameters, ErrorDecryptingParameters } from '../../Types/EncryptedParameters' +import { RootKeyEncryptedAuthenticatedData } from '../../Types/RootKeyEncryptedAuthenticatedData' +import { ItemAuthenticatedData } from '../../Types/ItemAuthenticatedData' +import { LegacyAttachedData } from '../../Types/LegacyAttachedData' +import { isItemsKey } from '../../Keys/ItemsKey' +import { CreateNewRootKey } from '../../Keys/RootKey/Functions' + +const NO_IV = '00000000000000000000000000000000' + +/** + * @deprecated + * A legacy operator no longer used to generate new accounts + */ +export class SNProtocolOperator001 implements AsynchronousOperator { + protected readonly crypto: PureCryptoInterface + + constructor(crypto: PureCryptoInterface) { + this.crypto = crypto + } + + public getEncryptionDisplayName(): string { + return 'AES-256' + } + + get version(): ProtocolVersion { + return ProtocolVersion.V001 + } + + protected generateNewItemsKeyContent(): ItemsKeyContent { + const keyLength = V001Algorithm.EncryptionKeyLength + const itemsKey = this.crypto.generateRandomKey(keyLength) + const response = FillItemContent({ + itemsKey: itemsKey, + version: ProtocolVersion.V001, + }) + return response + } + + /** + * Creates a new random items key to use for item encryption. + * The consumer must save/sync this item. + */ + public createItemsKey(): ItemsKeyInterface { + const payload = new DecryptedPayload({ + uuid: UuidGenerator.GenerateUuid(), + content_type: ContentType.ItemsKey, + content: this.generateNewItemsKeyContent(), + ...PayloadTimestampDefaults(), + }) + return CreateDecryptedItemFromPayload(payload) + } + + public async createRootKey( + identifier: string, + password: string, + origination: KeyParamsOrigination, + ): Promise { + const pwCost = V001Algorithm.PbkdfMinCost as number + const pwNonce = this.crypto.generateRandomKey(V001Algorithm.SaltSeedLength) + const pwSalt = await this.crypto.unsafeSha1(identifier + 'SN' + pwNonce) + + const keyParams = Create001KeyParams({ + email: identifier, + pw_cost: pwCost, + pw_nonce: pwNonce, + pw_salt: pwSalt, + version: ProtocolVersion.V001, + origination, + created: `${Date.now()}`, + }) + + return this.deriveKey(password, keyParams) + } + + public getPayloadAuthenticatedData( + _encrypted: EncryptedParameters, + ): RootKeyEncryptedAuthenticatedData | ItemAuthenticatedData | LegacyAttachedData | undefined { + return undefined + } + + public async computeRootKey(password: string, keyParams: SNRootKeyParams): Promise { + return this.deriveKey(password, keyParams) + } + + private async decryptString(ciphertext: string, key: string) { + return this.crypto.aes256CbcDecrypt(ciphertext, NO_IV, key) + } + + private async encryptString(text: string, key: string) { + return this.crypto.aes256CbcEncrypt(text, NO_IV, key) + } + + public async generateEncryptedParametersAsync( + payload: DecryptedPayloadInterface, + key: ItemsKeyInterface | SNRootKey, + ): Promise { + /** + * Generate new item key that is double the key size. + * Will be split to create encryption key and authentication key. + */ + const itemKey = this.crypto.generateRandomKey(V001Algorithm.EncryptionKeyLength * 2) + const encItemKey = await this.encryptString(itemKey, key.itemsKey) + + /** Encrypt content */ + const ek = firstHalfOfString(itemKey) + const ak = secondHalfOfString(itemKey) + const contentCiphertext = await this.encryptString(JSON.stringify(payload.content), ek) + const ciphertext = key.keyVersion + contentCiphertext + const authHash = await this.crypto.hmac256(ciphertext, ak) + + if (!authHash) { + throw Error('Error generating hmac256 authHash') + } + + return { + uuid: payload.uuid, + items_key_id: isItemsKey(key) ? key.uuid : undefined, + content: ciphertext, + enc_item_key: encItemKey, + auth_hash: authHash, + version: this.version, + } + } + + public async generateDecryptedParametersAsync( + encrypted: EncryptedParameters, + key: ItemsKeyInterface | SNRootKey, + ): Promise | ErrorDecryptingParameters> { + if (!encrypted.enc_item_key) { + console.error(Error('Missing item encryption key, skipping decryption.')) + return { + uuid: encrypted.uuid, + errorDecrypting: true, + } + } + + let encryptedItemKey = encrypted.enc_item_key + encryptedItemKey = this.version + encryptedItemKey + const itemKeyComponents = this.encryptionComponentsFromString(encryptedItemKey, key.itemsKey) + + const itemKey = await this.decryptString(itemKeyComponents.ciphertext, itemKeyComponents.key) + if (!itemKey) { + console.error('Error decrypting parameters', encrypted) + return { + uuid: encrypted.uuid, + errorDecrypting: true, + } + } + + const ek = firstHalfOfString(itemKey) + const itemParams = this.encryptionComponentsFromString(encrypted.content, ek) + const content = await this.decryptString(itemParams.ciphertext, itemParams.key) + + if (!content) { + return { + uuid: encrypted.uuid, + errorDecrypting: true, + } + } else { + return { + uuid: encrypted.uuid, + content: JSON.parse(content), + } + } + } + + private encryptionComponentsFromString(string: string, encryptionKey: string) { + const encryptionVersion = string.substring(0, ProtocolVersionLength) + return { + ciphertext: string.substring(ProtocolVersionLength, string.length), + version: encryptionVersion, + key: encryptionKey, + } + } + + protected async deriveKey(password: string, keyParams: SNRootKeyParams): Promise { + const derivedKey = await this.crypto.pbkdf2( + password, + keyParams.content001.pw_salt, + keyParams.content001.pw_cost, + V001Algorithm.PbkdfOutputLength, + ) + + if (!derivedKey) { + throw Error('Error deriving PBKDF2 key') + } + + const partitions = splitString(derivedKey, 2) + + return CreateNewRootKey({ + serverPassword: partitions[0], + masterKey: partitions[1], + version: ProtocolVersion.V001, + keyParams: keyParams.getPortableValue(), + }) + } +} diff --git a/packages/encryption/src/Domain/Operator/002/Operator002.ts b/packages/encryption/src/Domain/Operator/002/Operator002.ts new file mode 100644 index 000000000..5e7c0820d --- /dev/null +++ b/packages/encryption/src/Domain/Operator/002/Operator002.ts @@ -0,0 +1,296 @@ +import { Create002KeyParams } from '../../Keys/RootKey/KeyParamsFunctions' +import { SNProtocolOperator001 } from '../001/Operator001' +import { SNRootKey } from '../../Keys/RootKey/RootKey' +import { SNRootKeyParams } from '../../Keys/RootKey/RootKeyParams' +import { UuidGenerator } from '@standardnotes/utils' +import { V002Algorithm } from '../../Algorithm' +import * as Common from '@standardnotes/common' +import * as Models from '@standardnotes/models' +import * as Utils from '@standardnotes/utils' +import { DecryptedParameters, EncryptedParameters, ErrorDecryptingParameters } from '../../Types/EncryptedParameters' +import { RootKeyEncryptedAuthenticatedData } from '../../Types/RootKeyEncryptedAuthenticatedData' +import { ItemAuthenticatedData } from '../../Types/ItemAuthenticatedData' +import { LegacyAttachedData } from '../../Types/LegacyAttachedData' +import { isItemsKey } from '../../Keys/ItemsKey' +import { CreateNewRootKey } from '../../Keys/RootKey/Functions' +import { ItemContent, PayloadTimestampDefaults } from '@standardnotes/models' + +/** + * @deprecated + * A legacy operator no longer used to generate new accounts. + */ +export class SNProtocolOperator002 extends SNProtocolOperator001 { + override get version(): Common.ProtocolVersion { + return Common.ProtocolVersion.V002 + } + + protected override generateNewItemsKeyContent(): Models.ItemsKeyContent { + const keyLength = V002Algorithm.EncryptionKeyLength + const itemsKey = this.crypto.generateRandomKey(keyLength) + const authKey = this.crypto.generateRandomKey(keyLength) + const response = Models.FillItemContent({ + itemsKey: itemsKey, + dataAuthenticationKey: authKey, + version: Common.ProtocolVersion.V002, + }) + return response + } + + /** + * Creates a new random items key to use for item encryption. + * The consumer must save/sync this item. + */ + public override createItemsKey(): Models.ItemsKeyInterface { + const payload = new Models.DecryptedPayload({ + uuid: UuidGenerator.GenerateUuid(), + content_type: Common.ContentType.ItemsKey, + content: this.generateNewItemsKeyContent(), + ...PayloadTimestampDefaults(), + }) + return Models.CreateDecryptedItemFromPayload(payload) + } + + public override async createRootKey( + identifier: string, + password: string, + origination: Common.KeyParamsOrigination, + ): Promise { + const pwCost = Utils.lastElement(V002Algorithm.PbkdfCostsUsed) as number + const pwNonce = this.crypto.generateRandomKey(V002Algorithm.SaltSeedLength) + const pwSalt = await this.crypto.unsafeSha1(identifier + ':' + pwNonce) + + const keyParams = Create002KeyParams({ + email: identifier, + pw_nonce: pwNonce, + pw_cost: pwCost, + pw_salt: pwSalt, + version: Common.ProtocolVersion.V002, + origination, + created: `${Date.now()}`, + }) + + return this.deriveKey(password, keyParams) + } + + /** + * Note that version 002 supported "dynamic" iteration counts. Some accounts + * may have had costs of 5000, and others of 101000. Therefore, when computing + * the root key, we must use the value returned by the server. + */ + public override async computeRootKey(password: string, keyParams: SNRootKeyParams): Promise { + return this.deriveKey(password, keyParams) + } + + private async decryptString002(text: string, key: string, iv: string) { + return this.crypto.aes256CbcDecrypt(text, iv, key) + } + + private async encryptString002(text: string, key: string, iv: string) { + return this.crypto.aes256CbcEncrypt(text, iv, key) + } + + /** + * @param keyParams Supplied only when encrypting an items key + */ + private async encryptTextParams( + string: string, + encryptionKey: string, + authKey: string, + uuid: string, + version: Common.ProtocolVersion, + keyParams?: SNRootKeyParams, + ) { + const iv = this.crypto.generateRandomKey(V002Algorithm.EncryptionIvLength) + const contentCiphertext = await this.encryptString002(string, encryptionKey, iv) + const ciphertextToAuth = [version, uuid, iv, contentCiphertext].join(':') + const authHash = await this.crypto.hmac256(ciphertextToAuth, authKey) + + if (!authHash) { + throw Error('Error generating hmac256 authHash') + } + + const components: string[] = [version as string, authHash, uuid, iv, contentCiphertext] + if (keyParams) { + const keyParamsString = this.crypto.base64Encode(JSON.stringify(keyParams.content)) + components.push(keyParamsString) + } + const fullCiphertext = components.join(':') + return fullCiphertext + } + + private async decryptTextParams( + ciphertextToAuth: string, + contentCiphertext: string, + encryptionKey: string, + iv: string, + authHash: string, + authKey: string, + ) { + if (!encryptionKey) { + throw 'Attempting to decryptTextParams with null encryptionKey' + } + const localAuthHash = await this.crypto.hmac256(ciphertextToAuth, authKey) + if (!localAuthHash) { + throw Error('Error generating hmac256 localAuthHash') + } + + if (this.crypto.timingSafeEqual(authHash, localAuthHash) === false) { + console.error(Error('Auth hash does not match.')) + return null + } + return this.decryptString002(contentCiphertext, encryptionKey, iv) + } + + public override getPayloadAuthenticatedData( + encrypted: EncryptedParameters, + ): RootKeyEncryptedAuthenticatedData | ItemAuthenticatedData | LegacyAttachedData | undefined { + const itemKeyComponents = this.encryptionComponentsFromString002(encrypted.enc_item_key) + const authenticatedData = itemKeyComponents.keyParams + + if (!authenticatedData) { + return undefined + } + + const decoded = JSON.parse(this.crypto.base64Decode(authenticatedData)) + const data: LegacyAttachedData = { + ...(decoded as Common.AnyKeyParamsContent), + } + return data + } + + public override async generateEncryptedParametersAsync( + payload: Models.DecryptedPayloadInterface, + key: Models.ItemsKeyInterface | SNRootKey, + ): Promise { + /** + * Generate new item key that is double the key size. + * Will be split to create encryption key and authentication key. + */ + const itemKey = this.crypto.generateRandomKey(V002Algorithm.EncryptionKeyLength * 2) + const encItemKey = await this.encryptTextParams( + itemKey, + key.itemsKey, + key.dataAuthenticationKey as string, + payload.uuid, + key.keyVersion, + key instanceof SNRootKey ? (key as SNRootKey).keyParams : undefined, + ) + + const ek = Utils.firstHalfOfString(itemKey) + const ak = Utils.secondHalfOfString(itemKey) + const ciphertext = await this.encryptTextParams( + JSON.stringify(payload.content), + ek, + ak, + payload.uuid, + key.keyVersion, + key instanceof SNRootKey ? (key as SNRootKey).keyParams : undefined, + ) + + return { + uuid: payload.uuid, + items_key_id: isItemsKey(key) ? key.uuid : undefined, + content: ciphertext, + enc_item_key: encItemKey, + version: this.version, + } + } + + public override async generateDecryptedParametersAsync( + encrypted: EncryptedParameters, + key: Models.ItemsKeyInterface | SNRootKey, + ): Promise | ErrorDecryptingParameters> { + if (!encrypted.enc_item_key) { + console.error(Error('Missing item encryption key, skipping decryption.')) + return { + uuid: encrypted.uuid, + errorDecrypting: true, + } + } + + const encryptedItemKey = encrypted.enc_item_key + const itemKeyComponents = this.encryptionComponentsFromString002( + encryptedItemKey, + key.itemsKey, + key.dataAuthenticationKey, + ) + + const itemKey = await this.decryptTextParams( + itemKeyComponents.ciphertextToAuth, + itemKeyComponents.contentCiphertext, + itemKeyComponents.encryptionKey as string, + itemKeyComponents.iv, + itemKeyComponents.authHash, + itemKeyComponents.authKey as string, + ) + if (!itemKey) { + console.error('Error decrypting item_key parameters', encrypted) + return { + uuid: encrypted.uuid, + errorDecrypting: true, + } + } + + const ek = Utils.firstHalfOfString(itemKey) + const ak = Utils.secondHalfOfString(itemKey) + const itemParams = this.encryptionComponentsFromString002(encrypted.content, ek, ak) + const content = await this.decryptTextParams( + itemParams.ciphertextToAuth, + itemParams.contentCiphertext, + itemParams.encryptionKey as string, + itemParams.iv, + itemParams.authHash, + itemParams.authKey as string, + ) + + if (!content) { + return { + uuid: encrypted.uuid, + errorDecrypting: true, + } + } else { + return { + uuid: encrypted.uuid, + content: JSON.parse(content), + } + } + } + + protected override async deriveKey(password: string, keyParams: SNRootKeyParams): Promise { + const derivedKey = await this.crypto.pbkdf2( + password, + keyParams.content002.pw_salt, + keyParams.content002.pw_cost, + V002Algorithm.PbkdfOutputLength, + ) + + if (!derivedKey) { + throw Error('Error deriving PBKDF2 key') + } + + const partitions = Utils.splitString(derivedKey, 3) + + return CreateNewRootKey({ + serverPassword: partitions[0], + masterKey: partitions[1], + dataAuthenticationKey: partitions[2], + version: Common.ProtocolVersion.V002, + keyParams: keyParams.getPortableValue(), + }) + } + + private encryptionComponentsFromString002(string: string, encryptionKey?: string, authKey?: string) { + const components = string.split(':') + return { + encryptionVersion: components[0], + authHash: components[1], + uuid: components[2], + iv: components[3], + contentCiphertext: components[4], + keyParams: components[5], + ciphertextToAuth: [components[0], components[2], components[3], components[4]].join(':'), + encryptionKey: encryptionKey, + authKey: authKey, + } + } +} diff --git a/packages/encryption/src/Domain/Operator/003/Operator003.ts b/packages/encryption/src/Domain/Operator/003/Operator003.ts new file mode 100644 index 000000000..abf2ffc86 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/003/Operator003.ts @@ -0,0 +1,111 @@ +import { splitString, UuidGenerator } from '@standardnotes/utils' +import { + CreateDecryptedItemFromPayload, + DecryptedPayload, + ItemsKeyContent, + ItemsKeyInterface, + FillItemContent, + PayloadTimestampDefaults, +} from '@standardnotes/models' +import { SNRootKey } from '../../Keys/RootKey/RootKey' +import { V003Algorithm } from '../../Algorithm' +import { Create003KeyParams } from '../../Keys/RootKey/KeyParamsFunctions' +import { SNProtocolOperator002 } from '../002/Operator002' +import { ContentType, KeyParamsOrigination, ProtocolVersion } from '@standardnotes/common' +import { SNRootKeyParams } from '../../Keys/RootKey/RootKeyParams' +import { CreateNewRootKey } from '../../Keys/RootKey/Functions' + +/** + * @legacy + * Non-expired operator but no longer used for generating new accounts. + * This operator subclasses the 002 operator to share functionality that has not + * changed, and overrides functions where behavior may differ. + */ +export class SNProtocolOperator003 extends SNProtocolOperator002 { + override get version(): ProtocolVersion { + return ProtocolVersion.V003 + } + + protected override generateNewItemsKeyContent(): ItemsKeyContent { + const keyLength = V003Algorithm.EncryptionKeyLength + const itemsKey = this.crypto.generateRandomKey(keyLength) + const authKey = this.crypto.generateRandomKey(keyLength) + const response = FillItemContent({ + itemsKey: itemsKey, + dataAuthenticationKey: authKey, + version: ProtocolVersion.V003, + }) + return response + } + + /** + * Creates a new random items key to use for item encryption. + * The consumer must save/sync this item. + */ + public override createItemsKey(): ItemsKeyInterface { + const content = this.generateNewItemsKeyContent() + const payload = new DecryptedPayload({ + uuid: UuidGenerator.GenerateUuid(), + content_type: ContentType.ItemsKey, + content: FillItemContent(content), + ...PayloadTimestampDefaults(), + }) + return CreateDecryptedItemFromPayload(payload) + } + + public override async computeRootKey(password: string, keyParams: SNRootKeyParams): Promise { + return this.deriveKey(password, keyParams) + } + + protected override async deriveKey(password: string, keyParams: SNRootKeyParams): Promise { + const salt = await this.generateSalt( + keyParams.content003.identifier, + ProtocolVersion.V003, + V003Algorithm.PbkdfCost, + keyParams.content003.pw_nonce, + ) + + const derivedKey = await this.crypto.pbkdf2( + password, + salt, + V003Algorithm.PbkdfCost, + V003Algorithm.PbkdfOutputLength, + ) + + if (!derivedKey) { + throw Error('Error deriving PBKDF2 key') + } + + const partitions = splitString(derivedKey, 3) + + return CreateNewRootKey({ + serverPassword: partitions[0], + masterKey: partitions[1], + dataAuthenticationKey: partitions[2], + version: ProtocolVersion.V003, + keyParams: keyParams.getPortableValue(), + }) + } + + public override async createRootKey( + identifier: string, + password: string, + origination: KeyParamsOrigination, + ): Promise { + const version = ProtocolVersion.V003 + const pwNonce = this.crypto.generateRandomKey(V003Algorithm.SaltSeedLength) + const keyParams = Create003KeyParams({ + identifier: identifier, + pw_nonce: pwNonce, + version: version, + origination: origination, + created: `${Date.now()}`, + }) + return this.deriveKey(password, keyParams) + } + + private async generateSalt(identifier: string, version: ProtocolVersion, cost: number, nonce: string) { + const result = await this.crypto.sha256([identifier, 'SF', version, cost, nonce].join(':')) + return result + } +} diff --git a/packages/encryption/src/Domain/Operator/004/Operator004.spec.ts b/packages/encryption/src/Domain/Operator/004/Operator004.spec.ts new file mode 100644 index 000000000..51674ca55 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/Operator004.spec.ts @@ -0,0 +1,96 @@ +import { ContentType, ProtocolVersion } from '@standardnotes/common' +import { DecryptedPayload, ItemContent, ItemsKeyContent, PayloadTimestampDefaults } from '@standardnotes/models' +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { SNItemsKey } from '../../Keys/ItemsKey' +import { ItemAuthenticatedData } from '../../Types/ItemAuthenticatedData' +import { SNProtocolOperator004 } from './Operator004' + +const b64 = (text: string): string => { + return Buffer.from(text).toString('base64') +} + +describe('operator 004', () => { + let crypto: PureCryptoInterface + let operator: SNProtocolOperator004 + + beforeEach(() => { + crypto = {} as jest.Mocked + crypto.base64Encode = jest.fn().mockImplementation((text: string) => { + return b64(text) + }) + crypto.base64Decode = jest.fn().mockImplementation((text: string) => { + return Buffer.from(text, 'base64').toString('ascii') + }) + crypto.xchacha20Encrypt = jest.fn().mockImplementation((text: string) => { + return `${text}` + }) + crypto.xchacha20Decrypt = jest.fn().mockImplementation((text: string) => { + return text.split('')[1] + }) + crypto.generateRandomKey = jest.fn().mockImplementation(() => { + return 'random-string' + }) + + operator = new SNProtocolOperator004(crypto) + }) + + it('should generateEncryptedProtocolString', () => { + const aad: ItemAuthenticatedData = { + u: '123', + v: ProtocolVersion.V004, + } + + const nonce = 'noncy' + const plaintext = 'foo' + + operator.generateEncryptionNonce = jest.fn().mockReturnValue(nonce) + + const result = operator.generateEncryptedProtocolString(plaintext, 'secret', aad) + + expect(result).toEqual(`004:${nonce}:${plaintext}:${b64(JSON.stringify(aad))}`) + }) + + it('should deconstructEncryptedPayloadString', () => { + const string = '004:noncy:foo:eyJ1IjoiMTIzIiwidiI6IjAwNCJ9' + + const result = operator.deconstructEncryptedPayloadString(string) + + expect(result).toEqual({ + version: '004', + nonce: 'noncy', + ciphertext: 'foo', + authenticatedData: 'eyJ1IjoiMTIzIiwidiI6IjAwNCJ9', + }) + }) + + it('should generateEncryptedParametersSync', () => { + const payload = { + uuid: '123', + content_type: ContentType.Note, + content: { foo: 'bar' } as unknown as jest.Mocked, + ...PayloadTimestampDefaults(), + } as jest.Mocked + + const key = new SNItemsKey( + new DecryptedPayload({ + uuid: 'key-456', + content_type: ContentType.ItemsKey, + content: { + itemsKey: 'secret', + version: ProtocolVersion.V004, + } as jest.Mocked, + ...PayloadTimestampDefaults(), + }), + ) + + const result = operator.generateEncryptedParametersSync(payload, key) + + expect(result).toEqual({ + uuid: '123', + items_key_id: 'key-456', + content: '004:random-string:{"foo":"bar"}:eyJ1IjoiMTIzIiwidiI6IjAwNCJ9', + enc_item_key: '004:random-string:random-string:eyJ1IjoiMTIzIiwidiI6IjAwNCJ9', + version: '004', + }) + }) +}) diff --git a/packages/encryption/src/Domain/Operator/004/Operator004.ts b/packages/encryption/src/Domain/Operator/004/Operator004.ts new file mode 100644 index 000000000..b8316a5e3 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/004/Operator004.ts @@ -0,0 +1,319 @@ +import { ContentType, KeyParamsOrigination, ProtocolVersion } from '@standardnotes/common' +import { Create004KeyParams } from '../../Keys/RootKey/KeyParamsFunctions' +import { SynchronousOperator } from '../Operator' +import { + CreateDecryptedItemFromPayload, + FillItemContent, + ItemContent, + ItemsKeyContent, + ItemsKeyInterface, + PayloadTimestampDefaults, +} from '@standardnotes/models' +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { SNRootKey } from '../../Keys/RootKey/RootKey' +import { SNRootKeyParams } from '../../Keys/RootKey/RootKeyParams' +import { V004Algorithm } from '../../Algorithm' +import * as Models from '@standardnotes/models' +import * as Utils from '@standardnotes/utils' +import { ContentTypeUsesRootKeyEncryption, CreateNewRootKey } from '../../Keys/RootKey/Functions' +import { DecryptedParameters, EncryptedParameters, ErrorDecryptingParameters } from '../../Types/EncryptedParameters' +import { RootKeyEncryptedAuthenticatedData } from '../../Types/RootKeyEncryptedAuthenticatedData' +import { ItemAuthenticatedData } from '../../Types/ItemAuthenticatedData' +import { LegacyAttachedData } from '../../Types/LegacyAttachedData' +import { isItemsKey } from '../../Keys/ItemsKey' + +type V004StringComponents = [version: string, nonce: string, ciphertext: string, authenticatedData: string] + +type V004Components = { + version: V004StringComponents[0] + nonce: V004StringComponents[1] + ciphertext: V004StringComponents[2] + authenticatedData: V004StringComponents[3] +} + +const PARTITION_CHARACTER = ':' + +export class SNProtocolOperator004 implements SynchronousOperator { + protected readonly crypto: PureCryptoInterface + + constructor(crypto: PureCryptoInterface) { + this.crypto = crypto + } + + public getEncryptionDisplayName(): string { + return 'XChaCha20-Poly1305' + } + + get version(): ProtocolVersion { + return ProtocolVersion.V004 + } + + private generateNewItemsKeyContent() { + const itemsKey = this.crypto.generateRandomKey(V004Algorithm.EncryptionKeyLength) + const response = FillItemContent({ + itemsKey: itemsKey, + version: ProtocolVersion.V004, + }) + return response + } + + /** + * Creates a new random items key to use for item encryption. + * The consumer must save/sync this item. + */ + public createItemsKey(): ItemsKeyInterface { + const payload = new Models.DecryptedPayload({ + uuid: Utils.UuidGenerator.GenerateUuid(), + content_type: ContentType.ItemsKey, + content: this.generateNewItemsKeyContent(), + ...PayloadTimestampDefaults(), + }) + return CreateDecryptedItemFromPayload(payload) + } + + /** + * We require both a client-side component and a server-side component in generating a + * salt. This way, a comprimised server cannot benefit from sending the same seed value + * for every user. We mix a client-controlled value that is globally unique + * (their identifier), with a server controlled value to produce a salt for our KDF. + * @param identifier + * @param seed + */ + private async generateSalt004(identifier: string, seed: string) { + const hash = await this.crypto.sha256([identifier, seed].join(PARTITION_CHARACTER)) + return Utils.truncateHexString(hash, V004Algorithm.ArgonSaltLength) + } + + /** + * Computes a root key given a passworf + * qwd and previous keyParams + * @param password - Plain string representing raw user password + * @param keyParams - KeyParams object + */ + public async computeRootKey(password: string, keyParams: SNRootKeyParams): Promise { + return this.deriveKey(password, keyParams) + } + + /** + * Creates a new root key given an identifier and a user password + * @param identifier - Plain string representing a unique identifier + * @param password - Plain string representing raw user password + */ + public async createRootKey( + identifier: string, + password: string, + origination: KeyParamsOrigination, + ): Promise { + const version = ProtocolVersion.V004 + const seed = this.crypto.generateRandomKey(V004Algorithm.ArgonSaltSeedLength) + const keyParams = Create004KeyParams({ + identifier: identifier, + pw_nonce: seed, + version: version, + origination: origination, + created: `${Date.now()}`, + }) + return this.deriveKey(password, keyParams) + } + + /** + * @param plaintext - The plaintext to encrypt. + * @param rawKey - The key to use to encrypt the plaintext. + * @param nonce - The nonce for encryption. + * @param authenticatedData - JavaScript object (will be stringified) representing + 'Additional authenticated data': data you want to be included in authentication. + */ + encryptString004(plaintext: string, rawKey: string, nonce: string, authenticatedData: ItemAuthenticatedData) { + if (!nonce) { + throw 'encryptString null nonce' + } + if (!rawKey) { + throw 'encryptString null rawKey' + } + return this.crypto.xchacha20Encrypt(plaintext, nonce, rawKey, this.authenticatedDataToString(authenticatedData)) + } + + /** + * @param ciphertext The encrypted text to decrypt. + * @param rawKey The key to use to decrypt the ciphertext. + * @param nonce The nonce for decryption. + * @param rawAuthenticatedData String representing + 'Additional authenticated data' - data you want to be included in authentication. + */ + private decryptString004(ciphertext: string, rawKey: string, nonce: string, rawAuthenticatedData: string) { + return this.crypto.xchacha20Decrypt(ciphertext, nonce, rawKey, rawAuthenticatedData) + } + + generateEncryptionNonce(): string { + return this.crypto.generateRandomKey(V004Algorithm.EncryptionNonceLength) + } + + /** + * @param plaintext The plaintext text to decrypt. + * @param rawKey The key to use to encrypt the plaintext. + */ + generateEncryptedProtocolString(plaintext: string, rawKey: string, authenticatedData: ItemAuthenticatedData) { + const nonce = this.generateEncryptionNonce() + + const ciphertext = this.encryptString004(plaintext, rawKey, nonce, authenticatedData) + + const components: V004StringComponents = [ + ProtocolVersion.V004 as string, + nonce, + ciphertext, + this.authenticatedDataToString(authenticatedData), + ] + + return components.join(PARTITION_CHARACTER) + } + + deconstructEncryptedPayloadString(payloadString: string): V004Components { + const components = payloadString.split(PARTITION_CHARACTER) as V004StringComponents + + return { + version: components[0], + nonce: components[1], + ciphertext: components[2], + authenticatedData: components[3], + } + } + + public getPayloadAuthenticatedData( + encrypted: EncryptedParameters, + ): RootKeyEncryptedAuthenticatedData | ItemAuthenticatedData | LegacyAttachedData | undefined { + const itemKeyComponents = this.deconstructEncryptedPayloadString(encrypted.enc_item_key) + const authenticatedDataString = itemKeyComponents.authenticatedData + const result = this.stringToAuthenticatedData(authenticatedDataString) + + return result + } + + /** + * For items that are encrypted with a root key, we append the root key's key params, so + * that in the event the client/user loses a reference to their root key, they may still + * decrypt data by regenerating the key based on the attached key params. + */ + private generateAuthenticatedDataForPayload( + payload: Models.DecryptedPayloadInterface, + key: ItemsKeyInterface | SNRootKey, + ): ItemAuthenticatedData | RootKeyEncryptedAuthenticatedData { + const baseData: ItemAuthenticatedData = { + u: payload.uuid, + v: ProtocolVersion.V004, + } + if (ContentTypeUsesRootKeyEncryption(payload.content_type)) { + return { + ...baseData, + kp: (key as SNRootKey).keyParams.content, + } + } else { + if (!isItemsKey(key)) { + throw Error('Attempting to use non-items key for regular item.') + } + return baseData + } + } + + private authenticatedDataToString(attachedData: ItemAuthenticatedData) { + return this.crypto.base64Encode(JSON.stringify(Utils.sortedCopy(Utils.omitUndefinedCopy(attachedData)))) + } + + private stringToAuthenticatedData( + rawAuthenticatedData: string, + override?: Partial, + ): RootKeyEncryptedAuthenticatedData | ItemAuthenticatedData { + const base = JSON.parse(this.crypto.base64Decode(rawAuthenticatedData)) + return Utils.sortedCopy({ + ...base, + ...override, + }) + } + + public generateEncryptedParametersSync( + payload: Models.DecryptedPayloadInterface, + key: ItemsKeyInterface | SNRootKey, + ): EncryptedParameters { + const itemKey = this.crypto.generateRandomKey(V004Algorithm.EncryptionKeyLength) + + const contentPlaintext = JSON.stringify(payload.content) + const authenticatedData = this.generateAuthenticatedDataForPayload(payload, key) + const encryptedContentString = this.generateEncryptedProtocolString(contentPlaintext, itemKey, authenticatedData) + + const encryptedItemKey = this.generateEncryptedProtocolString(itemKey, key.itemsKey, authenticatedData) + + return { + uuid: payload.uuid, + items_key_id: isItemsKey(key) ? key.uuid : undefined, + content: encryptedContentString, + enc_item_key: encryptedItemKey, + version: this.version, + } + } + + public generateDecryptedParametersSync( + encrypted: EncryptedParameters, + key: ItemsKeyInterface | SNRootKey, + ): DecryptedParameters | ErrorDecryptingParameters { + const itemKeyComponents = this.deconstructEncryptedPayloadString(encrypted.enc_item_key) + const authenticatedData = this.stringToAuthenticatedData(itemKeyComponents.authenticatedData, { + u: encrypted.uuid, + v: encrypted.version, + }) + + const useAuthenticatedString = this.authenticatedDataToString(authenticatedData) + const itemKey = this.decryptString004( + itemKeyComponents.ciphertext, + key.itemsKey, + itemKeyComponents.nonce, + useAuthenticatedString, + ) + + if (!itemKey) { + console.error('Error decrypting itemKey parameters', encrypted) + return { + uuid: encrypted.uuid, + errorDecrypting: true, + } + } + + const contentComponents = this.deconstructEncryptedPayloadString(encrypted.content) + const content = this.decryptString004( + contentComponents.ciphertext, + itemKey, + contentComponents.nonce, + useAuthenticatedString, + ) + if (!content) { + return { + uuid: encrypted.uuid, + errorDecrypting: true, + } + } else { + return { + uuid: encrypted.uuid, + content: JSON.parse(content), + } + } + } + + private async deriveKey(password: string, keyParams: SNRootKeyParams): Promise { + const salt = await this.generateSalt004(keyParams.content004.identifier, keyParams.content004.pw_nonce) + const derivedKey = this.crypto.argon2( + password, + salt, + V004Algorithm.ArgonIterations, + V004Algorithm.ArgonMemLimit, + V004Algorithm.ArgonOutputKeyBytes, + ) + const partitions = Utils.splitString(derivedKey, 2) + const masterKey = partitions[0] + const serverPassword = partitions[1] + + return CreateNewRootKey({ + masterKey, + serverPassword, + version: ProtocolVersion.V004, + keyParams: keyParams.getPortableValue(), + }) + } +} diff --git a/packages/encryption/src/Domain/Operator/Functions.ts b/packages/encryption/src/Domain/Operator/Functions.ts new file mode 100644 index 000000000..160cd1f4d --- /dev/null +++ b/packages/encryption/src/Domain/Operator/Functions.ts @@ -0,0 +1,30 @@ +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { SNProtocolOperator001 } from '../Operator/001/Operator001' +import { SNProtocolOperator002 } from '../Operator/002/Operator002' +import { SNProtocolOperator003 } from '../Operator/003/Operator003' +import { SNProtocolOperator004 } from '../Operator/004/Operator004' +import { AsynchronousOperator, SynchronousOperator } from '../Operator/Operator' +import { ProtocolVersion } from '@standardnotes/common' + +export function createOperatorForVersion( + version: ProtocolVersion, + crypto: PureCryptoInterface, +): AsynchronousOperator | SynchronousOperator { + if (version === ProtocolVersion.V001) { + return new SNProtocolOperator001(crypto) + } else if (version === ProtocolVersion.V002) { + return new SNProtocolOperator002(crypto) + } else if (version === ProtocolVersion.V003) { + return new SNProtocolOperator003(crypto) + } else if (version === ProtocolVersion.V004) { + return new SNProtocolOperator004(crypto) + } else { + throw Error(`Unable to find operator for version ${version}`) + } +} + +export function isAsyncOperator( + operator: AsynchronousOperator | SynchronousOperator, +): operator is AsynchronousOperator { + return (operator as AsynchronousOperator).generateDecryptedParametersAsync !== undefined +} diff --git a/packages/encryption/src/Domain/Operator/Operator.ts b/packages/encryption/src/Domain/Operator/Operator.ts new file mode 100644 index 000000000..5b4599bb3 --- /dev/null +++ b/packages/encryption/src/Domain/Operator/Operator.ts @@ -0,0 +1,86 @@ +import { ItemsKeyInterface, RootKeyInterface } from '@standardnotes/models' +import * as Models from '@standardnotes/models' +import { SNRootKey } from '../Keys/RootKey/RootKey' +import { SNRootKeyParams } from '../Keys/RootKey/RootKeyParams' +import { KeyParamsOrigination } from '@standardnotes/common' +import { DecryptedParameters, EncryptedParameters, ErrorDecryptingParameters } from '../Types/EncryptedParameters' +import { RootKeyEncryptedAuthenticatedData } from '../Types/RootKeyEncryptedAuthenticatedData' +import { ItemAuthenticatedData } from '../Types/ItemAuthenticatedData' +import { LegacyAttachedData } from '../Types/LegacyAttachedData' + +/**w + * An operator is responsible for performing crypto operations, such as generating keys + * and encrypting/decrypting payloads. Operators interact directly with + * platform dependent SNPureCrypto implementation to directly access cryptographic primitives. + * Each operator is versioned according to the protocol version. Functions that are common + * across all versions appear in this generic parent class. + */ +export interface OperatorCommon { + createItemsKey(): ItemsKeyInterface + /** + * Returns encryption protocol display name + */ + getEncryptionDisplayName(): string + + readonly version: string + + /** + * Returns the payload's authenticated data. The passed payload must be in a + * non-decrypted, ciphertext state. + */ + getPayloadAuthenticatedData( + encrypted: EncryptedParameters, + ): RootKeyEncryptedAuthenticatedData | ItemAuthenticatedData | LegacyAttachedData | undefined + + /** + * Computes a root key given a password and previous keyParams + * @param password - Plain string representing raw user password + */ + computeRootKey(password: string, keyParams: SNRootKeyParams): Promise + + /** + * Creates a new root key given an identifier and a user password + * @param identifier - Plain string representing a unique identifier + * for the user + * @param password - Plain string representing raw user password + */ + createRootKey(identifier: string, password: string, origination: KeyParamsOrigination): Promise +} + +export interface SynchronousOperator extends OperatorCommon { + /** + * Converts a bare payload into an encrypted one in the desired format. + * @param payload - The non-encrypted payload object to encrypt + * @param key - The key to use to encrypt the payload. Can be either + * a RootKey (when encrypting payloads that require root key encryption, such as encrypting + * items keys), or an ItemsKey (if encrypted regular items) + */ + generateEncryptedParametersSync( + payload: Models.DecryptedPayloadInterface, + key: ItemsKeyInterface | RootKeyInterface, + ): EncryptedParameters + + generateDecryptedParametersSync( + encrypted: EncryptedParameters, + key: ItemsKeyInterface | RootKeyInterface, + ): DecryptedParameters | ErrorDecryptingParameters +} + +export interface AsynchronousOperator extends OperatorCommon { + /** + * Converts a bare payload into an encrypted one in the desired format. + * @param payload - The non-encrypted payload object to encrypt + * @param key - The key to use to encrypt the payload. Can be either + * a RootKey (when encrypting payloads that require root key encryption, such as encrypting + * items keys), or an ItemsKey (if encrypted regular items) + */ + generateEncryptedParametersAsync( + payload: Models.DecryptedPayloadInterface, + key: ItemsKeyInterface | RootKeyInterface, + ): Promise + + generateDecryptedParametersAsync( + encrypted: EncryptedParameters, + key: ItemsKeyInterface | RootKeyInterface, + ): Promise | ErrorDecryptingParameters> +} diff --git a/packages/encryption/src/Domain/Operator/OperatorManager.ts b/packages/encryption/src/Domain/Operator/OperatorManager.ts new file mode 100644 index 000000000..128b5753e --- /dev/null +++ b/packages/encryption/src/Domain/Operator/OperatorManager.ts @@ -0,0 +1,34 @@ +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { ProtocolVersion, ProtocolVersionLatest } from '@standardnotes/common' +import { createOperatorForVersion } from './Functions' +import { AsynchronousOperator, SynchronousOperator } from './Operator' + +export class OperatorManager { + private operators: Record = {} + + constructor(private crypto: PureCryptoInterface) { + this.crypto = crypto + } + + public deinit(): void { + ;(this.crypto as unknown) = undefined + this.operators = {} + } + + public operatorForVersion(version: ProtocolVersion): SynchronousOperator | AsynchronousOperator { + const operatorKey = version + let operator = this.operators[operatorKey] + if (!operator) { + operator = createOperatorForVersion(version, this.crypto) + this.operators[operatorKey] = operator + } + return operator + } + + /** + * Returns the operator corresponding to the latest protocol version + */ + public defaultOperator(): SynchronousOperator | AsynchronousOperator { + return this.operatorForVersion(ProtocolVersionLatest) + } +} diff --git a/packages/encryption/src/Domain/Operator/OperatorWrapper.ts b/packages/encryption/src/Domain/Operator/OperatorWrapper.ts new file mode 100644 index 000000000..0b0663bff --- /dev/null +++ b/packages/encryption/src/Domain/Operator/OperatorWrapper.ts @@ -0,0 +1,52 @@ +import { isAsyncOperator } from './Functions' +import { OperatorManager } from './OperatorManager' +import * as Models from '@standardnotes/models' +import { + DecryptedParameters, + EncryptedParameters, + encryptedParametersFromPayload, + ErrorDecryptingParameters, +} from '../Types/EncryptedParameters' + +export async function encryptPayload( + payload: Models.DecryptedPayloadInterface, + key: Models.ItemsKeyInterface | Models.RootKeyInterface, + operatorManager: OperatorManager, +): Promise { + const operator = operatorManager.operatorForVersion(key.keyVersion) + let encryptionParameters + + if (isAsyncOperator(operator)) { + encryptionParameters = await operator.generateEncryptedParametersAsync(payload, key) + } else { + encryptionParameters = operator.generateEncryptedParametersSync(payload, key) + } + + if (!encryptionParameters) { + throw 'Unable to generate encryption parameters' + } + + return encryptionParameters +} + +export async function decryptPayload( + payload: Models.EncryptedPayloadInterface, + key: Models.ItemsKeyInterface | Models.RootKeyInterface, + operatorManager: OperatorManager, +): Promise | ErrorDecryptingParameters> { + const operator = operatorManager.operatorForVersion(payload.version) + + try { + if (isAsyncOperator(operator)) { + return await operator.generateDecryptedParametersAsync(encryptedParametersFromPayload(payload), key) + } else { + return operator.generateDecryptedParametersSync(encryptedParametersFromPayload(payload), key) + } + } catch (e) { + console.error('Error decrypting payload', payload, e) + return { + uuid: payload.uuid, + errorDecrypting: true, + } + } +} diff --git a/packages/encryption/src/Domain/Operator/index.ts b/packages/encryption/src/Domain/Operator/index.ts new file mode 100644 index 000000000..992e81eeb --- /dev/null +++ b/packages/encryption/src/Domain/Operator/index.ts @@ -0,0 +1,9 @@ +export * from './001/Operator001' +export * from './002/Operator002' +export * from './003/Operator003' +export * from './004/Operator004' +export * from './Operator' +export * from '../Types/EncryptedParameters' +export * from '../Types/ItemAuthenticatedData' +export * from '../Types/LegacyAttachedData' +export * from '../Types/RootKeyEncryptedAuthenticatedData' diff --git a/packages/encryption/src/Domain/Service/Encryption/EncryptionProvider.ts b/packages/encryption/src/Domain/Service/Encryption/EncryptionProvider.ts new file mode 100644 index 000000000..836038a45 --- /dev/null +++ b/packages/encryption/src/Domain/Service/Encryption/EncryptionProvider.ts @@ -0,0 +1,53 @@ +import { ProtocolVersion } from '@standardnotes/common' +import { + DecryptedPayloadInterface, + EncryptedPayloadInterface, + ItemContent, + RootKeyInterface, +} from '@standardnotes/models' +import { ClientDisplayableError } from '@standardnotes/responses' +import { BackupFile } from '../../Backups/BackupFile' +import { SNRootKeyParams } from '../../Keys/RootKey/RootKeyParams' +import { KeyedDecryptionSplit, KeyedEncryptionSplit } from '../../Split/EncryptionSplit' + +export interface EncryptionProvider { + encryptSplitSingle(split: KeyedEncryptionSplit): Promise + + encryptSplit(split: KeyedEncryptionSplit): Promise + + decryptSplitSingle< + C extends ItemContent = ItemContent, + P extends DecryptedPayloadInterface = DecryptedPayloadInterface, + >( + split: KeyedDecryptionSplit, + ): Promise

+ + decryptSplit< + C extends ItemContent = ItemContent, + P extends DecryptedPayloadInterface = DecryptedPayloadInterface, + >( + split: KeyedDecryptionSplit, + ): Promise<(P | EncryptedPayloadInterface)[]> + + hasRootKeyEncryptionSource(): boolean + + getKeyEmbeddedKeyParams(key: EncryptedPayloadInterface): SNRootKeyParams | undefined + + computeRootKey(password: string, keyParams: SNRootKeyParams): Promise + + /** + * @returns The versions that this library supports. + */ + supportedVersions(): ProtocolVersion[] + + getUserVersion(): ProtocolVersion | undefined + + /** + * Decrypts a backup file using user-inputted password + * @param password - The raw user password associated with this backup file + */ + decryptBackupFile( + file: BackupFile, + password?: string, + ): Promise +} diff --git a/packages/encryption/src/Domain/Service/Encryption/EncryptionService.ts b/packages/encryption/src/Domain/Service/Encryption/EncryptionService.ts new file mode 100644 index 000000000..335c22e11 --- /dev/null +++ b/packages/encryption/src/Domain/Service/Encryption/EncryptionService.ts @@ -0,0 +1,751 @@ +import { BackupFile } from '../../Backups/BackupFile' +import { CreateAnyKeyParams } from '../../Keys/RootKey/KeyParamsFunctions' +import { DecryptBackupFile } from '../../Backups/BackupFileDecryptor' +import { EncryptionProvider } from './EncryptionProvider' +import { findDefaultItemsKey } from '../Functions' +import { ItemsEncryptionService } from '../Items/ItemsEncryption' +import { KeyMode } from '../RootKey/KeyMode' +import { OperatorManager } from '../../Operator/OperatorManager' +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { SNRootKey } from '../../Keys/RootKey/RootKey' +import { SNRootKeyParams } from '../../Keys/RootKey/RootKeyParams' +import { V001Algorithm, V002Algorithm } from '../../Algorithm' +import * as Common from '@standardnotes/common' +import { + CreateEncryptionSplitWithKeyLookup, + FindPayloadInDecryptionSplit, + FindPayloadInEncryptionSplit, + KeyedDecryptionSplit, + KeyedEncryptionSplit, +} from '../../Split/EncryptionSplit' +import * as Models from '@standardnotes/models' +import * as RootKeyEncryption from '../RootKey/RootKeyEncryption' +import * as Services from '@standardnotes/services' +import * as Utils from '@standardnotes/utils' +import { + DecryptedParameters, + EncryptedParameters, + ErrorDecryptingParameters, + isErrorDecryptingParameters, + encryptedParametersFromPayload, +} from '../../Types/EncryptedParameters' +import { RootKeyEncryptedAuthenticatedData } from '../../Types/RootKeyEncryptedAuthenticatedData' +import { ItemAuthenticatedData } from '../../Types/ItemAuthenticatedData' +import { LegacyAttachedData } from '../../Types/LegacyAttachedData' +import { + CreateDecryptedBackupFileContextPayload, + CreateEncryptedBackupFileContextPayload, + EncryptedPayload, + isDecryptedPayload, + isEncryptedPayload, + RootKeyInterface, +} from '@standardnotes/models' +import { SplitPayloadsByEncryptionType } from '../../Split/EncryptionTypeSplit' +import { ClientDisplayableError } from '@standardnotes/responses' +import { isNotUndefined } from '@standardnotes/utils' +import { DiagnosticInfo } from '@standardnotes/services' + +export enum EncryptionServiceEvent { + RootKeyStatusChanged = 'RootKeyStatusChanged', +} + +/** + * The encryption service is responsible for the encryption and decryption of payloads, and + * handles delegation of a task to the respective protocol operator. Each version of the protocol + * (001, 002, 003, 004, etc) uses a respective operator version to perform encryption operations. + * Operators are located in /protocol/operator. + * The protocol service depends on the keyManager for determining which key to use for the + * encryption and decryption of a particular payload. + * The protocol service is also responsible for dictating which protocol versions are valid, + * and which are no longer valid or not supported. + + * The key manager is responsible for managing root key and root key wrapper states. + * When the key manager is initialized, it initiates itself with a keyMode, which + * dictates the entire flow of key management. The key manager's responsibilities include: + * - interacting with the device keychain to save or clear the root key + * - interacting with storage to save root key params or wrapper params, or the wrapped root key. + * - exposing methods that allow the application to unwrap the root key (unlock the application) + * + * It also exposes two primary methods for determining what key should be used to encrypt + * or decrypt a particular payload. Some payloads are encrypted directly with the rootKey + * (such as itemsKeys and encryptedStorage). Others are encrypted with itemsKeys (notes, tags, etc). + + * The items key manager manages the lifecycle of items keys. + * It is responsible for creating the default items key when conditions call for it + * (such as after the first sync completes and no key exists). + * It also exposes public methods that allows consumers to retrieve an items key + * for a particular payload, and also retrieve all available items keys. +*/ +export class EncryptionService extends Services.AbstractService implements EncryptionProvider { + private operatorManager: OperatorManager + private readonly itemsEncryption: ItemsEncryptionService + private readonly rootKeyEncryption: RootKeyEncryption.RootKeyEncryptionService + private rootKeyObserverDisposer: () => void + + constructor( + private itemManager: Services.ItemManagerInterface, + private payloadManager: Services.PayloadManagerInterface, + public deviceInterface: Services.DeviceInterface, + private storageService: Services.StorageServiceInterface, + private identifier: Common.ApplicationIdentifier, + public crypto: PureCryptoInterface, + protected override internalEventBus: Services.InternalEventBusInterface, + ) { + super(internalEventBus) + this.crypto = crypto + + this.operatorManager = new OperatorManager(crypto) + + this.itemsEncryption = new ItemsEncryptionService( + itemManager, + payloadManager, + storageService, + this.operatorManager, + internalEventBus, + ) + + this.rootKeyEncryption = new RootKeyEncryption.RootKeyEncryptionService( + this.itemManager, + this.operatorManager, + this.deviceInterface, + this.storageService, + this.identifier, + this.internalEventBus, + ) + this.rootKeyObserverDisposer = this.rootKeyEncryption.addEventObserver((event) => { + this.itemsEncryption.userVersion = this.getUserVersion() + if (event === RootKeyEncryption.RootKeyServiceEvent.RootKeyStatusChanged) { + void this.notifyEvent(EncryptionServiceEvent.RootKeyStatusChanged) + } + }) + + Utils.UuidGenerator.SetGenerator(this.crypto.generateUUID) + } + + public override deinit(): void { + ;(this.itemManager as unknown) = undefined + ;(this.payloadManager as unknown) = undefined + ;(this.deviceInterface as unknown) = undefined + ;(this.storageService as unknown) = undefined + ;(this.crypto as unknown) = undefined + ;(this.operatorManager as unknown) = undefined + + this.rootKeyObserverDisposer() + ;(this.rootKeyObserverDisposer as unknown) = undefined + + this.itemsEncryption.deinit() + ;(this.itemsEncryption as unknown) = undefined + + this.rootKeyEncryption.deinit() + ;(this.rootKeyEncryption as unknown) = undefined + + super.deinit() + } + + public async initialize() { + await this.rootKeyEncryption.initialize() + } + + /** + * Returns encryption protocol display name for active account/wrapper + */ + public async getEncryptionDisplayName(): Promise { + const version = await this.rootKeyEncryption.getEncryptionSourceVersion() + + if (version) { + return this.operatorManager.operatorForVersion(version).getEncryptionDisplayName() + } + + throw Error('Attempting to access encryption display name wtihout source') + } + + public getLatestVersion() { + return Common.ProtocolVersionLatest + } + + public hasAccount() { + return this.rootKeyEncryption.hasAccount() + } + + public hasRootKeyEncryptionSource(): boolean { + return this.rootKeyEncryption.hasRootKeyEncryptionSource() + } + + public getUserVersion(): Common.ProtocolVersion | undefined { + return this.rootKeyEncryption.getUserVersion() + } + + public async upgradeAvailable() { + const accountUpgradeAvailable = this.accountUpgradeAvailable() + const passcodeUpgradeAvailable = await this.passcodeUpgradeAvailable() + return accountUpgradeAvailable || passcodeUpgradeAvailable + } + + public getSureDefaultItemsKey(): Models.ItemsKeyInterface { + return this.itemsEncryption.getDefaultItemsKey() as Models.ItemsKeyInterface + } + + async repersistAllItems(): Promise { + return this.itemsEncryption.repersistAllItems() + } + + public async reencryptItemsKeys(): Promise { + await this.rootKeyEncryption.reencryptItemsKeys() + } + + public async createNewItemsKeyWithRollback(): Promise<() => Promise> { + return this.rootKeyEncryption.createNewItemsKeyWithRollback() + } + + public async decryptErroredPayloads(): Promise { + await this.itemsEncryption.decryptErroredPayloads() + } + + public itemsKeyForPayload(payload: Models.EncryptedPayloadInterface): Models.ItemsKeyInterface | undefined { + return this.itemsEncryption.itemsKeyForPayload(payload) + } + + public defaultItemsKeyForItemVersion( + version: Common.ProtocolVersion, + fromKeys?: Models.ItemsKeyInterface[], + ): Models.ItemsKeyInterface | undefined { + return this.itemsEncryption.defaultItemsKeyForItemVersion(version, fromKeys) + } + + public async encryptSplitSingle(split: KeyedEncryptionSplit): Promise { + return (await this.encryptSplit(split))[0] + } + + public async encryptSplit(split: KeyedEncryptionSplit): Promise { + const allEncryptedParams: EncryptedParameters[] = [] + + if (split.usesRootKey) { + const rootKeyEncrypted = await this.rootKeyEncryption.encryptPayloads( + split.usesRootKey.items, + split.usesRootKey.key, + ) + Utils.extendArray(allEncryptedParams, rootKeyEncrypted) + } + + if (split.usesItemsKey) { + const itemsKeyEncrypted = await this.itemsEncryption.encryptPayloads( + split.usesItemsKey.items, + split.usesItemsKey.key, + ) + Utils.extendArray(allEncryptedParams, itemsKeyEncrypted) + } + + if (split.usesRootKeyWithKeyLookup) { + const rootKeyEncrypted = await this.rootKeyEncryption.encryptPayloadsWithKeyLookup( + split.usesRootKeyWithKeyLookup.items, + ) + Utils.extendArray(allEncryptedParams, rootKeyEncrypted) + } + + if (split.usesItemsKeyWithKeyLookup) { + const itemsKeyEncrypted = await this.itemsEncryption.encryptPayloadsWithKeyLookup( + split.usesItemsKeyWithKeyLookup.items, + ) + Utils.extendArray(allEncryptedParams, itemsKeyEncrypted) + } + + const packagedEncrypted = allEncryptedParams.map((encryptedParams) => { + const original = FindPayloadInEncryptionSplit(encryptedParams.uuid, split) + return new EncryptedPayload({ + ...original, + ...encryptedParams, + waitingForKey: false, + errorDecrypting: false, + }) + }) + + return packagedEncrypted + } + + public async decryptSplitSingle< + C extends Models.ItemContent = Models.ItemContent, + P extends Models.DecryptedPayloadInterface = Models.DecryptedPayloadInterface, + >(split: KeyedDecryptionSplit): Promise

{ + const results = await this.decryptSplit(split) + return results[0] + } + + public async decryptSplit< + C extends Models.ItemContent = Models.ItemContent, + P extends Models.DecryptedPayloadInterface = Models.DecryptedPayloadInterface, + >(split: KeyedDecryptionSplit): Promise<(P | Models.EncryptedPayloadInterface)[]> { + const resultParams: (DecryptedParameters | ErrorDecryptingParameters)[] = [] + + if (split.usesRootKey) { + const rootKeyDecrypted = await this.rootKeyEncryption.decryptPayloads( + split.usesRootKey.items, + split.usesRootKey.key, + ) + Utils.extendArray(resultParams, rootKeyDecrypted) + } + + if (split.usesRootKeyWithKeyLookup) { + const rootKeyDecrypted = await this.rootKeyEncryption.decryptPayloadsWithKeyLookup( + split.usesRootKeyWithKeyLookup.items, + ) + Utils.extendArray(resultParams, rootKeyDecrypted) + } + + if (split.usesItemsKey) { + const itemsKeyDecrypted = await this.itemsEncryption.decryptPayloads( + split.usesItemsKey.items, + split.usesItemsKey.key, + ) + Utils.extendArray(resultParams, itemsKeyDecrypted) + } + + if (split.usesItemsKeyWithKeyLookup) { + const itemsKeyDecrypted = await this.itemsEncryption.decryptPayloadsWithKeyLookup( + split.usesItemsKeyWithKeyLookup.items, + ) + Utils.extendArray(resultParams, itemsKeyDecrypted) + } + + const packagedResults = resultParams.map((params) => { + const original = FindPayloadInDecryptionSplit(params.uuid, split) + + if (isErrorDecryptingParameters(params)) { + return new Models.EncryptedPayload({ + ...original.ejected(), + ...params, + }) + } else { + return new Models.DecryptedPayload({ + ...original.ejected(), + ...params, + }) as P + } + }) + + return packagedResults + } + + /** + * Returns true if the user's account protocol version is not equal to the latest version. + */ + public accountUpgradeAvailable(): boolean { + const userVersion = this.getUserVersion() + if (!userVersion) { + return false + } + return userVersion !== Common.ProtocolVersionLatest + } + + /** + * Returns true if the user's account protocol version is not equal to the latest version. + */ + public async passcodeUpgradeAvailable(): Promise { + return this.rootKeyEncryption.passcodeUpgradeAvailable() + } + + /** + * Determines whether the current environment is capable of supporting + * key derivation. + */ + public platformSupportsKeyDerivation(keyParams: SNRootKeyParams) { + /** + * If the version is 003 or lower, key derivation is supported unless the browser is + * IE or Edge (or generally, where WebCrypto is not available) or React Native environment is detected. + * + * Versions 004 and above are always supported. + */ + if (Common.compareVersions(keyParams.version, Common.ProtocolVersion.V004) >= 0) { + /* keyParams.version >= 004 */ + return true + } else { + return !!Utils.isWebCryptoAvailable() || Utils.isReactNativeEnvironment() + } + } + + public supportedVersions(): Common.ProtocolVersion[] { + return [ + Common.ProtocolVersion.V001, + Common.ProtocolVersion.V002, + Common.ProtocolVersion.V003, + Common.ProtocolVersion.V004, + ] + } + + /** + * Determines whether the input version is greater than the latest supported library version. + */ + public isVersionNewerThanLibraryVersion(version: Common.ProtocolVersion) { + const libraryVersion = Common.ProtocolVersionLatest + return Common.compareVersions(version, libraryVersion) === 1 + } + + /** + * Versions 001 and 002 of the protocol supported dynamic costs, as reported by the server. + * This function returns the client-enforced minimum cost, to prevent the server from + * overwhelmingly under-reporting the cost. + */ + public costMinimumForVersion(version: Common.ProtocolVersion) { + if (Common.compareVersions(version, Common.ProtocolVersion.V003) >= 0) { + throw 'Cost minimums only apply to versions <= 002' + } + if (version === Common.ProtocolVersion.V001) { + return V001Algorithm.PbkdfMinCost + } else if (version === Common.ProtocolVersion.V002) { + return V002Algorithm.PbkdfMinCost + } else { + throw `Invalid version for cost minimum: ${version}` + } + } + + /** + * Computes a root key given a password and key params. + * Delegates computation to respective protocol operator. + */ + public async computeRootKey(password: string, keyParams: SNRootKeyParams): Promise { + return this.rootKeyEncryption.computeRootKey(password, keyParams) + } + + /** + * Creates a root key using the latest protocol version + */ + public async createRootKey( + identifier: string, + password: string, + origination: Common.KeyParamsOrigination, + version?: Common.ProtocolVersion, + ) { + return this.rootKeyEncryption.createRootKey(identifier, password, origination, version) + } + + public async decryptBackupFile( + file: BackupFile, + password?: string, + ): Promise< + ClientDisplayableError | (Models.EncryptedPayloadInterface | Models.DecryptedPayloadInterface)[] + > { + const result = await DecryptBackupFile(file, this, password) + return result + } + + /** + * Creates a key params object from a raw object + * @param keyParams - The raw key params object to create a KeyParams object from + */ + public createKeyParams(keyParams: Common.AnyKeyParamsContent) { + return CreateAnyKeyParams(keyParams) + } + + public async createEncryptedBackupFile(): Promise { + const payloads = this.itemManager.items.map((item) => item.payload) + + const split = SplitPayloadsByEncryptionType(payloads) + + const keyLookupSplit = CreateEncryptionSplitWithKeyLookup(split) + + const result = await this.encryptSplit(keyLookupSplit) + + const ejected = result.map((payload) => CreateEncryptedBackupFileContextPayload(payload)) + + const data: BackupFile = { + version: Common.ProtocolVersionLatest, + items: ejected, + } + + const keyParams = await this.getRootKeyParams() + data.keyParams = keyParams?.getPortableValue() + return data + } + + public createDecryptedBackupFile(): BackupFile { + const payloads = this.payloadManager.nonDeletedItems.filter( + (item) => item.content_type !== Common.ContentType.ItemsKey, + ) + + const data: BackupFile = { + version: Common.ProtocolVersionLatest, + items: payloads + .map((payload) => { + if (isDecryptedPayload(payload)) { + return CreateDecryptedBackupFileContextPayload(payload) + } else if (isEncryptedPayload(payload)) { + return CreateEncryptedBackupFileContextPayload(payload) + } + return undefined + }) + .filter(isNotUndefined), + } + + return data + } + + public hasPasscode(): boolean { + return this.rootKeyEncryption.hasPasscode() + } + + /** + * @returns True if the root key has not yet been unwrapped (passcode locked). + */ + public async isPasscodeLocked() { + return (await this.rootKeyEncryption.hasRootKeyWrapper()) && this.rootKeyEncryption.getRootKey() == undefined + } + + public async getRootKeyParams() { + return this.rootKeyEncryption.getRootKeyParams() + } + + public getAccountKeyParams() { + return this.rootKeyEncryption.memoizedRootKeyParams + } + + /** + * Computes the root key wrapping key given a passcode. + * Wrapping key params are read from disk. + */ + public async computeWrappingKey(passcode: string) { + const keyParams = await this.rootKeyEncryption.getSureRootKeyWrapperKeyParams() + const key = await this.computeRootKey(passcode, keyParams) + return key + } + + /** + * Unwraps the persisted root key value using the supplied wrappingKey. + * Application interfaces must check to see if the root key requires unwrapping on load. + * If so, they must generate the unwrapping key by getting our saved wrapping key keyParams. + * After unwrapping, the root key is automatically loaded. + */ + public async unwrapRootKey(wrappingKey: RootKeyInterface) { + return this.rootKeyEncryption.unwrapRootKey(wrappingKey) + } + /** + * Encrypts rootKey and saves it in storage instead of keychain, and then + * clears keychain. This is because we don't want to store large encrypted + * payloads in the keychain. If the root key is not wrapped, it is stored + * in plain form in the user's secure keychain. + */ + public async setNewRootKeyWrapper(wrappingKey: SNRootKey) { + return this.rootKeyEncryption.setNewRootKeyWrapper(wrappingKey) + } + + public async removePasscode(): Promise { + await this.rootKeyEncryption.removeRootKeyWrapper() + } + + public async setRootKey(key: SNRootKey, wrappingKey?: SNRootKey) { + await this.rootKeyEncryption.setRootKey(key, wrappingKey) + } + + /** + * Returns the in-memory root key value. + */ + public getRootKey() { + return this.rootKeyEncryption.getRootKey() + } + + /** + * Deletes root key and wrapper from keychain. Used when signing out of application. + */ + public async deleteWorkspaceSpecificKeyStateFromDevice() { + await this.rootKeyEncryption.deleteWorkspaceSpecificKeyStateFromDevice() + } + + public async validateAccountPassword(password: string) { + return this.rootKeyEncryption.validateAccountPassword(password) + } + + public async validatePasscode(passcode: string) { + return this.rootKeyEncryption.validatePasscode(passcode) + } + + public getEmbeddedPayloadAuthenticatedData( + payload: Models.EncryptedPayloadInterface, + ): RootKeyEncryptedAuthenticatedData | ItemAuthenticatedData | LegacyAttachedData | undefined { + const version = payload.version + if (!version) { + return undefined + } + const operator = this.operatorManager.operatorForVersion(version) + const authenticatedData = operator.getPayloadAuthenticatedData(encryptedParametersFromPayload(payload)) + return authenticatedData + } + + /** Returns the key params attached to this key's encrypted payload */ + public getKeyEmbeddedKeyParams(key: Models.EncryptedPayloadInterface): SNRootKeyParams | undefined { + const authenticatedData = this.getEmbeddedPayloadAuthenticatedData(key) + if (!authenticatedData) { + return undefined + } + if (Common.isVersionLessThanOrEqualTo(key.version, Common.ProtocolVersion.V003)) { + const rawKeyParams = authenticatedData as LegacyAttachedData + return this.createKeyParams(rawKeyParams) + } else { + const rawKeyParams = (authenticatedData as RootKeyEncryptedAuthenticatedData).kp + return this.createKeyParams(rawKeyParams) + } + } + + /** + * A new rootkey-based items key is needed if a user changes their account password + * on an 003 client and syncs on a signed in 004 client. + */ + public needsNewRootKeyBasedItemsKey(): boolean { + if (!this.hasAccount()) { + return false + } + + const rootKey = this.rootKeyEncryption.getRootKey() + if (!rootKey) { + return false + } + + if (Common.compareVersions(rootKey.keyVersion, Common.ProtocolVersionLastNonrootItemsKey) > 0) { + /** Is >= 004, not needed */ + return false + } + + /** + * A new root key based items key is needed if our default items key content + * isnt equal to our current root key + */ + const defaultItemsKey = findDefaultItemsKey(this.itemsEncryption.getItemsKeys()) + + /** Shouldn't be undefined, but if it is, we'll take the corrective action */ + if (!defaultItemsKey) { + return true + } + + return defaultItemsKey.itemsKey !== rootKey.itemsKey + } + + public async createNewDefaultItemsKey(): Promise { + return this.rootKeyEncryption.createNewDefaultItemsKey() + } + + public getPasswordCreatedDate(): Date | undefined { + const rootKey = this.getRootKey() + return rootKey ? rootKey.keyParams.createdDate : undefined + } + + public async onSyncEvent(eventName: Services.SyncEvent) { + if (eventName === Services.SyncEvent.SyncCompletedWithAllItemsUploaded) { + await this.handleFullSyncCompletion() + } + if (eventName === Services.SyncEvent.DownloadFirstSyncCompleted) { + await this.handleDownloadFirstSyncCompletion() + } + } + + /** + * When a download-first sync completes, it means we've completed a (potentially multipage) + * sync where we only downloaded what the server had before uploading anything. We will be + * allowed to make local accomadations here before the server begins with the upload + * part of the sync (automatically runs after download-first sync completes). + * We use this to see if the server has any default itemsKeys, and if so, allows us to + * delete any never-synced items keys we have here locally. + */ + private async handleDownloadFirstSyncCompletion() { + if (!this.hasAccount()) { + return + } + + const itemsKeys = this.itemsEncryption.getItemsKeys() + + const neverSyncedKeys = itemsKeys.filter((key) => { + return key.neverSynced + }) + + const syncedKeys = itemsKeys.filter((key) => { + return !key.neverSynced + }) + + /** + * Find isDefault items key that have been previously synced. + * If we find one, this means we can delete any non-synced keys. + */ + const defaultSyncedKey = syncedKeys.find((key) => { + return key.isDefault + }) + + const hasSyncedItemsKey = !Utils.isNullOrUndefined(defaultSyncedKey) + if (hasSyncedItemsKey) { + /** Delete all never synced keys */ + await this.itemManager.setItemsToBeDeleted(neverSyncedKeys) + } else { + /** + * No previous synced items key. + * We can keep the one(s) we have, only if their version is equal to our root key + * version. If their version is not equal to our root key version, delete them. If + * we end up with 0 items keys, create a new one. This covers the case when you open + * the app offline and it creates an 004 key, and then you sign into an 003 account. + */ + const rootKeyParams = await this.getRootKeyParams() + if (rootKeyParams) { + /** If neverSynced.version != rootKey.version, delete. */ + const toDelete = neverSyncedKeys.filter((itemsKey) => { + return itemsKey.keyVersion !== rootKeyParams.version + }) + if (toDelete.length > 0) { + await this.itemManager.setItemsToBeDeleted(toDelete) + } + + if (this.itemsEncryption.getItemsKeys().length === 0) { + await this.rootKeyEncryption.createNewDefaultItemsKey() + } + } + } + /** If we do not have an items key for our current account version, create one */ + const userVersion = this.getUserVersion() + const accountVersionedKey = this.itemsEncryption.getItemsKeys().find((key) => key.keyVersion === userVersion) + if (Utils.isNullOrUndefined(accountVersionedKey)) { + await this.rootKeyEncryption.createNewDefaultItemsKey() + } + + this.syncUnsycnedItemsKeys() + } + + private async handleFullSyncCompletion() { + /** Always create a new items key after full sync, if no items key is found */ + const currentItemsKey = findDefaultItemsKey(this.itemsEncryption.getItemsKeys()) + if (!currentItemsKey) { + await this.rootKeyEncryption.createNewDefaultItemsKey() + if (this.rootKeyEncryption.keyMode === KeyMode.WrapperOnly) { + return this.itemsEncryption.repersistAllItems() + } + } + } + + /** + * There is presently an issue where an items key created while signed out of account ( + * or possibly signed in but with invalid session), then signing into account, results in that + * items key never syncing to the account even though it is being used to encrypt synced items. + * Until we can determine its cause, this corrective function will find any such keys and sync them. + */ + private syncUnsycnedItemsKeys(): void { + if (!this.hasAccount()) { + return + } + + const unsyncedKeys = this.itemsEncryption.getItemsKeys().filter((key) => key.neverSynced && !key.dirty) + if (unsyncedKeys.length > 0) { + void this.itemManager.setItemsDirty(unsyncedKeys) + } + } + + override async getDiagnostics(): Promise { + return { + encryption: { + getLatestVersion: this.getLatestVersion(), + hasAccount: this.hasAccount(), + hasRootKeyEncryptionSource: this.hasRootKeyEncryptionSource(), + getUserVersion: this.getUserVersion(), + upgradeAvailable: await this.upgradeAvailable(), + accountUpgradeAvailable: this.accountUpgradeAvailable(), + passcodeUpgradeAvailable: await this.passcodeUpgradeAvailable(), + hasPasscode: this.hasPasscode(), + isPasscodeLocked: await this.isPasscodeLocked(), + needsNewRootKeyBasedItemsKey: this.needsNewRootKeyBasedItemsKey(), + ...(await this.itemsEncryption.getDiagnostics()), + ...(await this.rootKeyEncryption.getDiagnostics()), + }, + } + } +} diff --git a/packages/encryption/src/Domain/Service/Functions.ts b/packages/encryption/src/Domain/Service/Functions.ts new file mode 100644 index 000000000..a7be0a521 --- /dev/null +++ b/packages/encryption/src/Domain/Service/Functions.ts @@ -0,0 +1,24 @@ +import { ItemsKeyInterface } from '@standardnotes/models' + +export function findDefaultItemsKey(itemsKeys: ItemsKeyInterface[]): ItemsKeyInterface | undefined { + if (itemsKeys.length === 1) { + return itemsKeys[0] + } + + const defaultKeys = itemsKeys.filter((key) => { + return key.isDefault + }) + + if (defaultKeys.length > 1) { + /** + * Prioritize one that is synced, as neverSynced keys will likely be deleted after + * DownloadFirst sync. + */ + const syncedKeys = defaultKeys.filter((key) => !key.neverSynced) + if (syncedKeys.length > 0) { + return syncedKeys[0] + } + } + + return defaultKeys[0] +} diff --git a/packages/encryption/src/Domain/Service/Items/ItemsEncryption.ts b/packages/encryption/src/Domain/Service/Items/ItemsEncryption.ts new file mode 100644 index 000000000..d9055b3b1 --- /dev/null +++ b/packages/encryption/src/Domain/Service/Items/ItemsEncryption.ts @@ -0,0 +1,250 @@ +import { ContentType, ProtocolVersion } from '@standardnotes/common' +import { findDefaultItemsKey } from '../Functions' +import { OperatorManager } from '../../Operator/OperatorManager' +import { StandardException } from '../../StandardException' +import * as OperatorWrapper from '../../Operator/OperatorWrapper' +import * as Models from '@standardnotes/models' +import * as Services from '@standardnotes/services' +import { + DecryptedParameters, + EncryptedParameters, + ErrorDecryptingParameters, + isErrorDecryptingParameters, +} from '../../Types/EncryptedParameters' +import { isEncryptedPayload } from '@standardnotes/models' +import { DiagnosticInfo } from '@standardnotes/services' +import { Uuids } from '@standardnotes/utils' + +export class ItemsEncryptionService extends Services.AbstractService { + private removeItemsObserver!: () => void + public userVersion?: ProtocolVersion + + constructor( + private itemManager: Services.ItemManagerInterface, + private payloadManager: Services.PayloadManagerInterface, + private storageService: Services.StorageServiceInterface, + private operatorManager: OperatorManager, + protected override internalEventBus: Services.InternalEventBusInterface, + ) { + super(internalEventBus) + + this.removeItemsObserver = this.itemManager.addObserver([ContentType.ItemsKey], ({ changed, inserted }) => { + if (changed.concat(inserted).length > 0) { + void this.decryptErroredPayloads() + } + }) + } + + public override deinit(): void { + ;(this.itemManager as unknown) = undefined + ;(this.payloadManager as unknown) = undefined + ;(this.storageService as unknown) = undefined + this.removeItemsObserver() + ;(this.removeItemsObserver as unknown) = undefined + super.deinit() + } + + /** + * If encryption status changes (esp. on mobile, where local storage encryption + * can be disabled), consumers may call this function to repersist all items to + * disk using latest encryption status. + */ + async repersistAllItems(): Promise { + const items = this.itemManager.items + const payloads = items.map((item) => item.payload) + return this.storageService.savePayloads(payloads) + } + + public getItemsKeys() { + return this.itemManager.getDisplayableItemsKeys() + } + + public itemsKeyForPayload(payload: Models.EncryptedPayloadInterface): Models.ItemsKeyInterface | undefined { + return this.getItemsKeys().find( + (key) => key.uuid === payload.items_key_id || key.duplicateOf === payload.items_key_id, + ) + } + + public getDefaultItemsKey(): Models.ItemsKeyInterface | undefined { + return findDefaultItemsKey(this.getItemsKeys()) + } + + private keyToUseForItemEncryption(): Models.ItemsKeyInterface | StandardException { + const defaultKey = this.getDefaultItemsKey() + let result: Models.ItemsKeyInterface | undefined = undefined + + if (this.userVersion && this.userVersion !== defaultKey?.keyVersion) { + /** + * The default key appears to be either newer or older than the user's account version + * We could throw an exception here, but will instead fall back to a corrective action: + * return any items key that corresponds to the user's version + */ + const itemsKeys = this.getItemsKeys() + result = itemsKeys.find((key) => key.keyVersion === this.userVersion) + } else { + result = defaultKey + } + + if (!result) { + return new StandardException('Cannot find items key to use for encryption') + } + + return result + } + + private keyToUseForDecryptionOfPayload( + payload: Models.EncryptedPayloadInterface, + ): Models.ItemsKeyInterface | undefined { + if (payload.items_key_id) { + const itemsKey = this.itemsKeyForPayload(payload) + return itemsKey + } + + const defaultKey = this.defaultItemsKeyForItemVersion(payload.version) + return defaultKey + } + + public async encryptPayloadWithKeyLookup(payload: Models.DecryptedPayloadInterface): Promise { + const key = this.keyToUseForItemEncryption() + + if (key instanceof StandardException) { + throw Error(key.message) + } + + return this.encryptPayload(payload, key) + } + + public async encryptPayload( + payload: Models.DecryptedPayloadInterface, + key: Models.ItemsKeyInterface, + ): Promise { + if (isEncryptedPayload(payload)) { + throw Error('Attempting to encrypt already encrypted payload.') + } + if (!payload.content) { + throw Error('Attempting to encrypt payload with no content.') + } + if (!payload.uuid) { + throw Error('Attempting to encrypt payload with no UuidGenerator.') + } + + return OperatorWrapper.encryptPayload(payload, key, this.operatorManager) + } + + public async encryptPayloads( + payloads: Models.DecryptedPayloadInterface[], + key: Models.ItemsKeyInterface, + ): Promise { + return Promise.all(payloads.map((payload) => this.encryptPayload(payload, key))) + } + + public async encryptPayloadsWithKeyLookup( + payloads: Models.DecryptedPayloadInterface[], + ): Promise { + return Promise.all(payloads.map((payload) => this.encryptPayloadWithKeyLookup(payload))) + } + + public async decryptPayloadWithKeyLookup( + payload: Models.EncryptedPayloadInterface, + ): Promise | ErrorDecryptingParameters> { + const key = this.keyToUseForDecryptionOfPayload(payload) + + if (key == undefined) { + return { + uuid: payload.uuid, + errorDecrypting: true, + waitingForKey: true, + } + } + + return this.decryptPayload(payload, key) + } + + public async decryptPayload( + payload: Models.EncryptedPayloadInterface, + key: Models.ItemsKeyInterface, + ): Promise | ErrorDecryptingParameters> { + if (!payload.content) { + return { + uuid: payload.uuid, + errorDecrypting: true, + } + } + + return OperatorWrapper.decryptPayload(payload, key, this.operatorManager) + } + + public async decryptPayloadsWithKeyLookup( + payloads: Models.EncryptedPayloadInterface[], + ): Promise<(DecryptedParameters | ErrorDecryptingParameters)[]> { + return Promise.all(payloads.map((payload) => this.decryptPayloadWithKeyLookup(payload))) + } + + public async decryptPayloads( + payloads: Models.EncryptedPayloadInterface[], + key: Models.ItemsKeyInterface, + ): Promise<(DecryptedParameters | ErrorDecryptingParameters)[]> { + return Promise.all(payloads.map((payload) => this.decryptPayload(payload, key))) + } + + public async decryptErroredPayloads(): Promise { + const payloads = this.payloadManager.invalidPayloads.filter((i) => i.content_type !== ContentType.ItemsKey) + if (payloads.length === 0) { + return + } + + const resultParams = await this.decryptPayloadsWithKeyLookup(payloads) + + const decryptedPayloads = resultParams.map((params) => { + const original = Models.SureFindPayload(payloads, params.uuid) + if (isErrorDecryptingParameters(params)) { + return new Models.EncryptedPayload({ + ...original.ejected(), + ...params, + }) + } else { + return new Models.DecryptedPayload({ + ...original.ejected(), + ...params, + }) + } + }) + + await this.payloadManager.emitPayloads(decryptedPayloads, Models.PayloadEmitSource.LocalChanged) + } + + /** + * When migrating from non-items key architecture, many items will not have a + * relationship with any key object. For those items, we can be sure that only 1 key + * object will correspond to that protocol version. + * @returns The items key object to decrypt items encrypted + * with previous protocol version. + */ + public defaultItemsKeyForItemVersion( + version: ProtocolVersion, + fromKeys?: Models.ItemsKeyInterface[], + ): Models.ItemsKeyInterface | undefined { + /** Try to find one marked default first */ + const searchKeys = fromKeys || this.getItemsKeys() + const priorityKey = searchKeys.find((key) => { + return key.isDefault && key.keyVersion === version + }) + if (priorityKey) { + return priorityKey + } + return searchKeys.find((key) => { + return key.keyVersion === version + }) + } + + override async getDiagnostics(): Promise { + const keyForItems = this.keyToUseForItemEncryption() + return { + itemsEncryption: { + itemsKeysIds: Uuids(this.getItemsKeys()), + defaultItemsKeyId: this.getDefaultItemsKey()?.uuid, + keyToUseForItemEncryptionId: keyForItems instanceof StandardException ? undefined : keyForItems.uuid, + }, + } + } +} diff --git a/packages/encryption/src/Domain/Service/RootKey/KeyMode.ts b/packages/encryption/src/Domain/Service/RootKey/KeyMode.ts new file mode 100644 index 000000000..00703a38e --- /dev/null +++ b/packages/encryption/src/Domain/Service/RootKey/KeyMode.ts @@ -0,0 +1,10 @@ +export enum KeyMode { + /** i.e No account and no passcode */ + RootKeyNone = 0, + /** i.e Account but no passcode */ + RootKeyOnly = 1, + /** i.e Account plus passcode */ + RootKeyPlusWrapper = 2, + /** i.e No account, but passcode */ + WrapperOnly = 3, +} diff --git a/packages/encryption/src/Domain/Service/RootKey/RootKeyEncryption.ts b/packages/encryption/src/Domain/Service/RootKey/RootKeyEncryption.ts new file mode 100644 index 000000000..c0450cb8d --- /dev/null +++ b/packages/encryption/src/Domain/Service/RootKey/RootKeyEncryption.ts @@ -0,0 +1,643 @@ +import { CreateAnyKeyParams } from '../../Keys/RootKey/KeyParamsFunctions' +import { findDefaultItemsKey } from '../Functions' +import { KeyMode } from './KeyMode' +import { OperatorManager } from '../../Operator/OperatorManager' +import { SNRootKey } from '../../Keys/RootKey/RootKey' +import { SNRootKeyParams } from '../../Keys/RootKey/RootKeyParams' +import { UuidGenerator } from '@standardnotes/utils' +import * as Common from '@standardnotes/common' +import * as Models from '@standardnotes/models' +import * as OperatorWrapper from '../../Operator/OperatorWrapper' +import * as Services from '@standardnotes/services' +import { + DecryptedParameters, + EncryptedParameters, + ErrorDecryptingParameters, + isErrorDecryptingParameters, +} from '../../Types/EncryptedParameters' +import { ItemsKeyMutator } from '../../Keys/ItemsKey' +import { CreateNewRootKey } from '../../Keys/RootKey/Functions' +import { + DecryptedPayload, + FillItemContentSpecialized, + ItemsKeyContent, + ItemsKeyContentSpecialized, + PayloadTimestampDefaults, + RootKeyContent, + RootKeyInterface, + NamespacedRootKeyInKeychain, +} from '@standardnotes/models' + +export enum RootKeyServiceEvent { + RootKeyStatusChanged = 'RootKeyStatusChanged', +} + +export class RootKeyEncryptionService extends Services.AbstractService { + private rootKey?: RootKeyInterface + public keyMode = KeyMode.RootKeyNone + public memoizedRootKeyParams?: SNRootKeyParams + + constructor( + private itemManager: Services.ItemManagerInterface, + private operatorManager: OperatorManager, + public deviceInterface: Services.DeviceInterface, + private storageService: Services.StorageServiceInterface, + private identifier: Common.ApplicationIdentifier, + protected override internalEventBus: Services.InternalEventBusInterface, + ) { + super(internalEventBus) + } + + public override deinit(): void { + ;(this.itemManager as unknown) = undefined + this.rootKey = undefined + this.memoizedRootKeyParams = undefined + super.deinit() + } + + public async initialize() { + const wrappedRootKey = this.getWrappedRootKey() + const accountKeyParams = await this.recomputeAccountKeyParams() + const hasWrapper = await this.hasRootKeyWrapper() + const hasRootKey = wrappedRootKey != undefined || accountKeyParams != undefined + + if (hasWrapper && hasRootKey) { + this.keyMode = KeyMode.RootKeyPlusWrapper + } else if (hasWrapper && !hasRootKey) { + this.keyMode = KeyMode.WrapperOnly + } else if (!hasWrapper && hasRootKey) { + this.keyMode = KeyMode.RootKeyOnly + } else if (!hasWrapper && !hasRootKey) { + this.keyMode = KeyMode.RootKeyNone + } else { + throw 'Invalid key mode condition' + } + + if (this.keyMode === KeyMode.RootKeyOnly) { + this.setRootKeyInstance(await this.getRootKeyFromKeychain()) + await this.handleKeyStatusChange() + } + } + + private async handleKeyStatusChange() { + await this.recomputeAccountKeyParams() + void this.notifyEvent(RootKeyServiceEvent.RootKeyStatusChanged) + } + + public async passcodeUpgradeAvailable() { + const passcodeParams = await this.getRootKeyWrapperKeyParams() + if (!passcodeParams) { + return false + } + return passcodeParams.version !== Common.ProtocolVersionLatest + } + + public async hasRootKeyWrapper() { + const wrapper = await this.getRootKeyWrapperKeyParams() + return wrapper != undefined + } + + public hasAccount() { + switch (this.keyMode) { + case KeyMode.RootKeyNone: + case KeyMode.WrapperOnly: + return false + case KeyMode.RootKeyOnly: + case KeyMode.RootKeyPlusWrapper: + return true + default: + throw Error(`Unhandled keyMode value '${this.keyMode}'.`) + } + } + + public hasRootKeyEncryptionSource(): boolean { + return this.hasAccount() || this.hasPasscode() + } + + public hasPasscode() { + return this.keyMode === KeyMode.WrapperOnly || this.keyMode === KeyMode.RootKeyPlusWrapper + } + + public async getEncryptionSourceVersion(): Promise { + if (this.hasAccount()) { + return this.getSureUserVersion() + } else if (this.hasPasscode()) { + const passcodeParams = await this.getSureRootKeyWrapperKeyParams() + return passcodeParams.version + } + + throw Error('Attempting to access encryption source version without source') + } + + public getUserVersion(): Common.ProtocolVersion | undefined { + const keyParams = this.memoizedRootKeyParams + return keyParams?.version + } + + private getSureUserVersion(): Common.ProtocolVersion { + const keyParams = this.memoizedRootKeyParams as SNRootKeyParams + return keyParams.version + } + + private async getRootKeyFromKeychain() { + const rawKey = (await this.deviceInterface.getNamespacedKeychainValue(this.identifier)) as + | NamespacedRootKeyInKeychain + | undefined + + if (rawKey == undefined) { + return undefined + } + + const keyParams = await this.getSureRootKeyParams() + + return CreateNewRootKey({ + ...rawKey, + keyParams: keyParams.getPortableValue(), + }) + } + + private async saveRootKeyToKeychain() { + if (this.getRootKey() == undefined) { + throw 'Attempting to non-existent root key to the keychain.' + } + if (this.keyMode !== KeyMode.RootKeyOnly) { + throw 'Should not be persisting wrapped key to keychain.' + } + + const rawKey = this.getSureRootKey().getKeychainValue() + + return this.executeCriticalFunction(() => { + return this.deviceInterface.setNamespacedKeychainValue(rawKey, this.identifier) + }) + } + + public async getRootKeyWrapperKeyParams(): Promise { + const rawKeyParams = await this.storageService.getValue( + Services.StorageKey.RootKeyWrapperKeyParams, + Services.StorageValueModes.Nonwrapped, + ) + + if (!rawKeyParams) { + return undefined + } + + return CreateAnyKeyParams(rawKeyParams as Common.AnyKeyParamsContent) + } + + public async getSureRootKeyWrapperKeyParams() { + return this.getRootKeyWrapperKeyParams() as Promise + } + + public async getRootKeyParams(): Promise { + if (this.keyMode === KeyMode.WrapperOnly) { + return this.getRootKeyWrapperKeyParams() + } else if (this.keyMode === KeyMode.RootKeyOnly || this.keyMode === KeyMode.RootKeyPlusWrapper) { + return this.recomputeAccountKeyParams() + } else if (this.keyMode === KeyMode.RootKeyNone) { + return undefined + } else { + throw `Unhandled key mode for getRootKeyParams ${this.keyMode}` + } + } + + public async getSureRootKeyParams(): Promise { + return this.getRootKeyParams() as Promise + } + + public async computeRootKey(password: string, keyParams: SNRootKeyParams): Promise { + const version = keyParams.version + const operator = this.operatorManager.operatorForVersion(version) + return operator.computeRootKey(password, keyParams) + } + + public async createRootKey( + identifier: string, + password: string, + origination: Common.KeyParamsOrigination, + version?: Common.ProtocolVersion, + ) { + const operator = version ? this.operatorManager.operatorForVersion(version) : this.operatorManager.defaultOperator() + return operator.createRootKey(identifier, password, origination) + } + + private getSureMemoizedRootKeyParams(): SNRootKeyParams { + return this.memoizedRootKeyParams as SNRootKeyParams + } + + public async validateAccountPassword(password: string) { + const key = await this.computeRootKey(password, this.getSureMemoizedRootKeyParams()) + const valid = this.getSureRootKey().compare(key) + if (valid) { + return { valid, artifacts: { rootKey: key } } + } else { + return { valid: false } + } + } + + public async validatePasscode(passcode: string) { + const keyParams = await this.getSureRootKeyWrapperKeyParams() + const key = await this.computeRootKey(passcode, keyParams) + const valid = await this.validateWrappingKey(key) + if (valid) { + return { valid, artifacts: { wrappingKey: key } } + } else { + return { valid: false } + } + } + + /** + * We know a wrappingKey is correct if it correctly decrypts + * wrapped root key. + */ + public async validateWrappingKey(wrappingKey: SNRootKey) { + const wrappedRootKey = this.getWrappedRootKey() + + /** If wrapper only, storage is encrypted directly with wrappingKey */ + if (this.keyMode === KeyMode.WrapperOnly) { + return this.storageService.canDecryptWithKey(wrappingKey) + } else if (this.keyMode === KeyMode.RootKeyOnly || this.keyMode === KeyMode.RootKeyPlusWrapper) { + /** + * In these modes, storage is encrypted with account keys, and + * account keys are encrypted with wrappingKey. Here we validate + * by attempting to decrypt account keys. + */ + const wrappedKeyPayload = new Models.EncryptedPayload(wrappedRootKey) + const decrypted = await this.decryptPayload(wrappedKeyPayload, wrappingKey) + return !isErrorDecryptingParameters(decrypted) + } else { + throw 'Unhandled case in validateWrappingKey' + } + } + + private async recomputeAccountKeyParams(): Promise { + const rawKeyParams = await this.storageService.getValue( + Services.StorageKey.RootKeyParams, + Services.StorageValueModes.Nonwrapped, + ) + + if (!rawKeyParams) { + return + } + + this.memoizedRootKeyParams = CreateAnyKeyParams(rawKeyParams as Common.AnyKeyParamsContent) + return this.memoizedRootKeyParams + } + + /** + * Wraps the current in-memory root key value using the wrappingKey, + * then persists the wrapped value to disk. + */ + private async wrapAndPersistRootKey(wrappingKey: SNRootKey) { + const rootKey = this.getSureRootKey() + const value: Models.DecryptedTransferPayload = { + ...rootKey.payload.ejected(), + content: FillItemContentSpecialized(rootKey.persistableValueWhenWrapping()), + } + const payload = new Models.DecryptedPayload(value) + + const wrappedKey = await this.encryptPayload(payload, wrappingKey) + const wrappedKeyPayload = new Models.EncryptedPayload({ + ...payload.ejected(), + ...wrappedKey, + errorDecrypting: false, + waitingForKey: false, + }) + + this.storageService.setValue( + Services.StorageKey.WrappedRootKey, + wrappedKeyPayload.ejected(), + Services.StorageValueModes.Nonwrapped, + ) + } + + public async unwrapRootKey(wrappingKey: RootKeyInterface) { + if (this.keyMode === KeyMode.WrapperOnly) { + this.setRootKeyInstance(wrappingKey) + return + } + + if (this.keyMode !== KeyMode.RootKeyPlusWrapper) { + throw 'Invalid key mode condition for unwrapping.' + } + + const wrappedKey = this.getWrappedRootKey() + const payload = new Models.EncryptedPayload(wrappedKey) + const decrypted = await this.decryptPayload(payload, wrappingKey) + + if (isErrorDecryptingParameters(decrypted)) { + throw Error('Unable to decrypt root key with provided wrapping key.') + } else { + const decryptedPayload = new DecryptedPayload({ + ...payload.ejected(), + ...decrypted, + }) + this.setRootKeyInstance(new SNRootKey(decryptedPayload)) + await this.handleKeyStatusChange() + } + } + + /** + * Encrypts rootKey and saves it in storage instead of keychain, and then + * clears keychain. This is because we don't want to store large encrypted + * payloads in the keychain. If the root key is not wrapped, it is stored + * in plain form in the user's secure keychain. + */ + public async setNewRootKeyWrapper(wrappingKey: SNRootKey) { + if (this.keyMode === KeyMode.RootKeyNone) { + this.keyMode = KeyMode.WrapperOnly + } else if (this.keyMode === KeyMode.RootKeyOnly) { + this.keyMode = KeyMode.RootKeyPlusWrapper + } else { + throw Error('Attempting to set wrapper on already wrapped key.') + } + + await this.deviceInterface.clearNamespacedKeychainValue(this.identifier) + + if (this.keyMode === KeyMode.WrapperOnly || this.keyMode === KeyMode.RootKeyPlusWrapper) { + if (this.keyMode === KeyMode.WrapperOnly) { + this.setRootKeyInstance(wrappingKey) + await this.reencryptItemsKeys() + } else { + await this.wrapAndPersistRootKey(wrappingKey) + } + + this.storageService.setValue( + Services.StorageKey.RootKeyWrapperKeyParams, + wrappingKey.keyParams.getPortableValue(), + Services.StorageValueModes.Nonwrapped, + ) + + await this.handleKeyStatusChange() + } else { + throw Error('Invalid keyMode on setNewRootKeyWrapper') + } + } + + /** + * Removes root key wrapper from local storage and stores root key bare in secure keychain. + */ + public async removeRootKeyWrapper(): Promise { + if (this.keyMode !== KeyMode.WrapperOnly && this.keyMode !== KeyMode.RootKeyPlusWrapper) { + throw Error('Attempting to remove root key wrapper on unwrapped key.') + } + + if (this.keyMode === KeyMode.WrapperOnly) { + this.keyMode = KeyMode.RootKeyNone + this.setRootKeyInstance(undefined) + } else if (this.keyMode === KeyMode.RootKeyPlusWrapper) { + this.keyMode = KeyMode.RootKeyOnly + } + + await this.storageService.removeValue(Services.StorageKey.WrappedRootKey, Services.StorageValueModes.Nonwrapped) + await this.storageService.removeValue( + Services.StorageKey.RootKeyWrapperKeyParams, + Services.StorageValueModes.Nonwrapped, + ) + + if (this.keyMode === KeyMode.RootKeyOnly) { + await this.saveRootKeyToKeychain() + } + + await this.handleKeyStatusChange() + } + + public async setRootKey(key: SNRootKey, wrappingKey?: SNRootKey) { + if (!key.keyParams) { + throw Error('keyParams must be supplied if setting root key.') + } + + if (this.getRootKey() === key) { + throw Error('Attempting to set root key as same current value.') + } + + if (this.keyMode === KeyMode.WrapperOnly) { + this.keyMode = KeyMode.RootKeyPlusWrapper + } else if (this.keyMode === KeyMode.RootKeyNone) { + this.keyMode = KeyMode.RootKeyOnly + } else if (this.keyMode === KeyMode.RootKeyOnly || this.keyMode === KeyMode.RootKeyPlusWrapper) { + /** Root key is simply changing, mode stays the same */ + /** this.keyMode = this.keyMode; */ + } else { + throw Error(`Unhandled key mode for setNewRootKey ${this.keyMode}`) + } + + this.setRootKeyInstance(key) + + this.storageService.setValue( + Services.StorageKey.RootKeyParams, + key.keyParams.getPortableValue(), + Services.StorageValueModes.Nonwrapped, + ) + + if (this.keyMode === KeyMode.RootKeyOnly) { + await this.saveRootKeyToKeychain() + } else if (this.keyMode === KeyMode.RootKeyPlusWrapper) { + if (!wrappingKey) { + throw Error('wrappingKey must be supplied') + } + await this.wrapAndPersistRootKey(wrappingKey) + } + + await this.handleKeyStatusChange() + } + + /** + * Deletes root key and wrapper from keychain. Used when signing out of application. + */ + public async deleteWorkspaceSpecificKeyStateFromDevice() { + await this.deviceInterface.clearNamespacedKeychainValue(this.identifier) + await this.storageService.removeValue(Services.StorageKey.WrappedRootKey, Services.StorageValueModes.Nonwrapped) + await this.storageService.removeValue( + Services.StorageKey.RootKeyWrapperKeyParams, + Services.StorageValueModes.Nonwrapped, + ) + await this.storageService.removeValue(Services.StorageKey.RootKeyParams, Services.StorageValueModes.Nonwrapped) + this.keyMode = KeyMode.RootKeyNone + this.setRootKeyInstance(undefined) + + await this.handleKeyStatusChange() + } + + private getWrappedRootKey() { + return this.storageService.getValue( + Services.StorageKey.WrappedRootKey, + Services.StorageValueModes.Nonwrapped, + ) + } + + public setRootKeyInstance(rootKey: RootKeyInterface | undefined): void { + this.rootKey = rootKey + } + + public getRootKey(): RootKeyInterface | undefined { + return this.rootKey + } + + private getSureRootKey(): RootKeyInterface { + return this.rootKey as RootKeyInterface + } + + private getItemsKeys() { + return this.itemManager.getDisplayableItemsKeys() + } + + public async encrypPayloadWithKeyLookup(payload: Models.DecryptedPayloadInterface): Promise { + const key = this.getRootKey() + + if (key == undefined) { + throw Error('Attempting root key encryption with no root key') + } + + return this.encryptPayload(payload, key) + } + + public async encryptPayloadsWithKeyLookup( + payloads: Models.DecryptedPayloadInterface[], + ): Promise { + return Promise.all(payloads.map((payload) => this.encrypPayloadWithKeyLookup(payload))) + } + + public async encryptPayload( + payload: Models.DecryptedPayloadInterface, + key: RootKeyInterface, + ): Promise { + return OperatorWrapper.encryptPayload(payload, key, this.operatorManager) + } + + public async encryptPayloads(payloads: Models.DecryptedPayloadInterface[], key: RootKeyInterface) { + return Promise.all(payloads.map((payload) => this.encryptPayload(payload, key))) + } + + public async decryptPayloadWithKeyLookup( + payload: Models.EncryptedPayloadInterface, + ): Promise | ErrorDecryptingParameters> { + const key = this.getRootKey() + + if (key == undefined) { + return { + uuid: payload.uuid, + errorDecrypting: true, + waitingForKey: true, + } + } + + return this.decryptPayload(payload, key) + } + + public async decryptPayload( + payload: Models.EncryptedPayloadInterface, + key: RootKeyInterface, + ): Promise | ErrorDecryptingParameters> { + return OperatorWrapper.decryptPayload(payload, key, this.operatorManager) + } + + public async decryptPayloadsWithKeyLookup( + payloads: Models.EncryptedPayloadInterface[], + ): Promise<(DecryptedParameters | ErrorDecryptingParameters)[]> { + return Promise.all(payloads.map((payload) => this.decryptPayloadWithKeyLookup(payload))) + } + + public async decryptPayloads( + payloads: Models.EncryptedPayloadInterface[], + key: RootKeyInterface, + ): Promise<(DecryptedParameters | ErrorDecryptingParameters)[]> { + return Promise.all(payloads.map((payload) => this.decryptPayload(payload, key))) + } + + /** + * When the root key changes (non-null only), we must re-encrypt all items + * keys with this new root key (by simply re-syncing). + */ + public async reencryptItemsKeys(): Promise { + const itemsKeys = this.getItemsKeys() + + if (itemsKeys.length > 0) { + /** + * Do not call sync after marking dirty. + * Re-encrypting items keys is called by consumers who have specific flows who + * will sync on their own timing + */ + await this.itemManager.setItemsDirty(itemsKeys) + } + } + + /** + * Creates a new random items key to use for item encryption, and adds it to model management. + * Consumer must call sync. If the protocol version <= 003, only one items key should be created, + * and its .itemsKey value should be equal to the root key masterKey value. + */ + public async createNewDefaultItemsKey(): Promise { + const rootKey = this.getSureRootKey() + const operatorVersion = rootKey ? rootKey.keyVersion : Common.ProtocolVersionLatest + let itemTemplate: Models.ItemsKeyInterface + + if (Common.compareVersions(operatorVersion, Common.ProtocolVersionLastNonrootItemsKey) <= 0) { + /** Create root key based items key */ + const payload = new DecryptedPayload({ + uuid: UuidGenerator.GenerateUuid(), + content_type: Common.ContentType.ItemsKey, + content: Models.FillItemContentSpecialized({ + itemsKey: rootKey.masterKey, + dataAuthenticationKey: rootKey.dataAuthenticationKey, + version: operatorVersion, + }), + ...PayloadTimestampDefaults(), + }) + itemTemplate = Models.CreateDecryptedItemFromPayload(payload) + } else { + /** Create independent items key */ + itemTemplate = this.operatorManager.operatorForVersion(operatorVersion).createItemsKey() + } + + const itemsKeys = this.getItemsKeys() + const defaultKeys = itemsKeys.filter((key) => { + return key.isDefault + }) + + for (const key of defaultKeys) { + await this.itemManager.changeItemsKey(key, (mutator) => { + mutator.isDefault = false + }) + } + + const itemsKey = (await this.itemManager.insertItem(itemTemplate)) as Models.ItemsKeyInterface + + await this.itemManager.changeItemsKey(itemsKey, (mutator) => { + mutator.isDefault = true + }) + + return itemsKey + } + + public async createNewItemsKeyWithRollback(): Promise<() => Promise> { + const currentDefaultItemsKey = findDefaultItemsKey(this.getItemsKeys()) + const newDefaultItemsKey = await this.createNewDefaultItemsKey() + + const rollback = async () => { + await this.itemManager.setItemToBeDeleted(newDefaultItemsKey) + + if (currentDefaultItemsKey) { + await this.itemManager.changeItem(currentDefaultItemsKey, (mutator) => { + mutator.isDefault = true + }) + } + } + + return rollback + } + + override async getDiagnostics(): Promise { + return { + rootKeyEncryption: { + hasRootKey: this.rootKey != undefined, + keyMode: KeyMode[this.keyMode], + hasRootKeyWrapper: await this.hasRootKeyWrapper(), + hasAccount: this.hasAccount(), + hasRootKeyEncryptionSource: this.hasRootKeyEncryptionSource(), + hasPasscode: this.hasPasscode(), + getEncryptionSourceVersion: this.hasRootKeyEncryptionSource() && (await this.getEncryptionSourceVersion()), + getUserVersion: this.getUserVersion(), + }, + } + } +} diff --git a/packages/encryption/src/Domain/Service/index.ts b/packages/encryption/src/Domain/Service/index.ts new file mode 100644 index 000000000..e15a9098e --- /dev/null +++ b/packages/encryption/src/Domain/Service/index.ts @@ -0,0 +1,3 @@ +export * from '../Backups/BackupFile' +export * from './Encryption/EncryptionService' +export * from './RootKey/KeyMode' diff --git a/packages/encryption/src/Domain/Split/EncryptionSplit.ts b/packages/encryption/src/Domain/Split/EncryptionSplit.ts new file mode 100644 index 000000000..197c2bfb1 --- /dev/null +++ b/packages/encryption/src/Domain/Split/EncryptionSplit.ts @@ -0,0 +1,109 @@ +import { Uuid } from '@standardnotes/common' +import { + DecryptedPayloadInterface, + EncryptedPayloadInterface, + ItemsKeyInterface, + PayloadInterface, + RootKeyInterface, +} from '@standardnotes/models' +import { EncryptionTypeSplit } from './EncryptionTypeSplit' + +export interface AbstractKeySplit { + usesRootKey?: { + items: T[] + key: RootKeyInterface + } + usesItemsKey?: { + items: T[] + key: ItemsKeyInterface + } + usesRootKeyWithKeyLookup?: { + items: T[] + } + usesItemsKeyWithKeyLookup?: { + items: T[] + } +} + +export type KeyedEncryptionSplit = AbstractKeySplit +export type KeyedDecryptionSplit = AbstractKeySplit + +export function CreateEncryptionSplitWithKeyLookup( + payloadSplit: EncryptionTypeSplit, +): KeyedEncryptionSplit { + const result: KeyedEncryptionSplit = {} + + if (payloadSplit.rootKeyEncryption) { + result.usesRootKeyWithKeyLookup = { items: payloadSplit.rootKeyEncryption } + } + + if (payloadSplit.itemsKeyEncryption) { + result.usesItemsKeyWithKeyLookup = { items: payloadSplit.itemsKeyEncryption } + } + + return result +} + +export function CreateDecryptionSplitWithKeyLookup( + payloadSplit: EncryptionTypeSplit, +): KeyedDecryptionSplit { + const result: KeyedDecryptionSplit = {} + + if (payloadSplit.rootKeyEncryption) { + result.usesRootKeyWithKeyLookup = { items: payloadSplit.rootKeyEncryption } + } + + if (payloadSplit.itemsKeyEncryption) { + result.usesItemsKeyWithKeyLookup = { items: payloadSplit.itemsKeyEncryption } + } + + return result +} + +export function FindPayloadInEncryptionSplit(uuid: Uuid, split: KeyedEncryptionSplit): DecryptedPayloadInterface { + const inUsesItemsKey = split.usesItemsKey?.items.find((item: PayloadInterface) => item.uuid === uuid) + if (inUsesItemsKey) { + return inUsesItemsKey + } + + const inUsesRootKey = split.usesRootKey?.items.find((item) => item.uuid === uuid) + if (inUsesRootKey) { + return inUsesRootKey + } + + const inUsesItemsKeyWithKeyLookup = split.usesItemsKeyWithKeyLookup?.items.find((item) => item.uuid === uuid) + if (inUsesItemsKeyWithKeyLookup) { + return inUsesItemsKeyWithKeyLookup + } + + const inUsesRootKeyWithKeyLookup = split.usesRootKeyWithKeyLookup?.items.find((item) => item.uuid === uuid) + if (inUsesRootKeyWithKeyLookup) { + return inUsesRootKeyWithKeyLookup + } + + throw Error('Cannot find payload in encryption split') +} + +export function FindPayloadInDecryptionSplit(uuid: Uuid, split: KeyedDecryptionSplit): EncryptedPayloadInterface { + const inUsesItemsKey = split.usesItemsKey?.items.find((item: PayloadInterface) => item.uuid === uuid) + if (inUsesItemsKey) { + return inUsesItemsKey + } + + const inUsesRootKey = split.usesRootKey?.items.find((item) => item.uuid === uuid) + if (inUsesRootKey) { + return inUsesRootKey + } + + const inUsesItemsKeyWithKeyLookup = split.usesItemsKeyWithKeyLookup?.items.find((item) => item.uuid === uuid) + if (inUsesItemsKeyWithKeyLookup) { + return inUsesItemsKeyWithKeyLookup + } + + const inUsesRootKeyWithKeyLookup = split.usesRootKeyWithKeyLookup?.items.find((item) => item.uuid === uuid) + if (inUsesRootKeyWithKeyLookup) { + return inUsesRootKeyWithKeyLookup + } + + throw Error('Cannot find payload in encryption split') +} diff --git a/packages/encryption/src/Domain/Split/EncryptionTypeSplit.ts b/packages/encryption/src/Domain/Split/EncryptionTypeSplit.ts new file mode 100644 index 000000000..c604c4410 --- /dev/null +++ b/packages/encryption/src/Domain/Split/EncryptionTypeSplit.ts @@ -0,0 +1,27 @@ +import { DecryptedPayloadInterface, EncryptedPayloadInterface } from '@standardnotes/models' +import { ItemContentTypeUsesRootKeyEncryption } from '../Keys/RootKey/Functions' + +export interface EncryptionTypeSplit { + rootKeyEncryption?: T[] + itemsKeyEncryption?: T[] +} + +export function SplitPayloadsByEncryptionType( + payloads: T[], +): EncryptionTypeSplit { + const usesRootKey: T[] = [] + const usesItemsKey: T[] = [] + + for (const item of payloads) { + if (ItemContentTypeUsesRootKeyEncryption(item.content_type)) { + usesRootKey.push(item) + } else { + usesItemsKey.push(item) + } + } + + return { + rootKeyEncryption: usesRootKey.length > 0 ? usesRootKey : undefined, + itemsKeyEncryption: usesItemsKey.length > 0 ? usesItemsKey : undefined, + } +} diff --git a/packages/encryption/src/Domain/StandardException.ts b/packages/encryption/src/Domain/StandardException.ts new file mode 100644 index 000000000..f80fe4d96 --- /dev/null +++ b/packages/encryption/src/Domain/StandardException.ts @@ -0,0 +1,7 @@ +export class StandardException { + constructor(public readonly message: string, log = false) { + if (log) { + console.error('StandardException raised: ', message) + } + } +} diff --git a/packages/encryption/src/Domain/Types/EncryptedParameters.ts b/packages/encryption/src/Domain/Types/EncryptedParameters.ts new file mode 100644 index 000000000..b5ef78996 --- /dev/null +++ b/packages/encryption/src/Domain/Types/EncryptedParameters.ts @@ -0,0 +1,41 @@ +import { ProtocolVersion } from '@standardnotes/common' +import { EncryptedPayloadInterface, ItemContent } from '@standardnotes/models' + +export type EncryptedParameters = { + uuid: string + content: string + items_key_id: string | undefined + enc_item_key: string + version: ProtocolVersion + + /** @deprecated */ + auth_hash?: string +} + +export type DecryptedParameters = { + uuid: string + content: C +} + +export type ErrorDecryptingParameters = { + uuid: string + errorDecrypting: true + waitingForKey?: boolean +} + +export function isErrorDecryptingParameters( + x: EncryptedParameters | DecryptedParameters | ErrorDecryptingParameters, +): x is ErrorDecryptingParameters { + return (x as ErrorDecryptingParameters).errorDecrypting +} + +export function encryptedParametersFromPayload(payload: EncryptedPayloadInterface): EncryptedParameters { + return { + uuid: payload.uuid, + content: payload.content, + items_key_id: payload.items_key_id, + enc_item_key: payload.enc_item_key as string, + version: payload.version, + auth_hash: payload.auth_hash, + } +} diff --git a/packages/encryption/src/Domain/Types/ItemAuthenticatedData.ts b/packages/encryption/src/Domain/Types/ItemAuthenticatedData.ts new file mode 100644 index 000000000..6c354c00e --- /dev/null +++ b/packages/encryption/src/Domain/Types/ItemAuthenticatedData.ts @@ -0,0 +1,6 @@ +import { Uuid, ProtocolVersion } from '@standardnotes/common' + +export type ItemAuthenticatedData = { + u: Uuid + v: ProtocolVersion +} diff --git a/packages/encryption/src/Domain/Types/LegacyAttachedData.ts b/packages/encryption/src/Domain/Types/LegacyAttachedData.ts new file mode 100644 index 000000000..3d226c771 --- /dev/null +++ b/packages/encryption/src/Domain/Types/LegacyAttachedData.ts @@ -0,0 +1,8 @@ +import { AnyKeyParamsContent } from '@standardnotes/common' + +/** + * <= V003 optionally included key params content as last component in encrypted string + * as a json stringified base64 representation. This data is attached but not included + * in authentication hash. + */ +export type LegacyAttachedData = AnyKeyParamsContent & Record diff --git a/packages/encryption/src/Domain/Types/RootKeyEncryptedAuthenticatedData.ts b/packages/encryption/src/Domain/Types/RootKeyEncryptedAuthenticatedData.ts new file mode 100644 index 000000000..32af86f8b --- /dev/null +++ b/packages/encryption/src/Domain/Types/RootKeyEncryptedAuthenticatedData.ts @@ -0,0 +1,8 @@ +import { AnyKeyParamsContent } from '@standardnotes/common' +import { ItemAuthenticatedData } from './ItemAuthenticatedData' + +/** Data that is attached to items that are encrypted with a root key */ +export type RootKeyEncryptedAuthenticatedData = ItemAuthenticatedData & { + /** The key params used to generate the root key that encrypts this item key */ + kp: AnyKeyParamsContent +} diff --git a/packages/encryption/src/Domain/Workspace/PrivateWorkspace.ts b/packages/encryption/src/Domain/Workspace/PrivateWorkspace.ts new file mode 100644 index 000000000..281b33496 --- /dev/null +++ b/packages/encryption/src/Domain/Workspace/PrivateWorkspace.ts @@ -0,0 +1,18 @@ +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' + +export async function ComputePrivateWorkspaceIdentifier( + crypto: PureCryptoInterface, + userphrase: string, + name: string, +): Promise { + const identifier = await crypto.hmac256( + await crypto.sha256(name.trim().toLowerCase()), + await crypto.sha256(userphrase.trim().toLowerCase()), + ) + + if (identifier == undefined) { + return undefined + } + + return identifier +} diff --git a/packages/encryption/src/Domain/index.ts b/packages/encryption/src/Domain/index.ts new file mode 100644 index 000000000..33bb939b2 --- /dev/null +++ b/packages/encryption/src/Domain/index.ts @@ -0,0 +1,15 @@ +export * from './Algorithm' +export * from './Split/EncryptionSplit' +export * from './Split/EncryptionTypeSplit' +export * from './Operator' +export * from './Keys/RootKey/KeyParamsFunctions' +export * from './Keys/RootKey/RootKey' +export * from './Keys/RootKey/RootKeyParams' +export * from './Keys/RootKey/Functions' +export * from './Service' +export * from './Service/Encryption/EncryptionProvider' +export * from './Split/EncryptionSplit' +export * from './Workspace/PrivateWorkspace' +export * from './Keys/ItemsKey' +export * from './Keys/Utils/DecryptItemsKey' +export * from './Keys/Utils/KeyRecoveryStrings' diff --git a/packages/encryption/src/index.ts b/packages/encryption/src/index.ts new file mode 100644 index 000000000..920deacdb --- /dev/null +++ b/packages/encryption/src/index.ts @@ -0,0 +1 @@ +export * from './Domain' diff --git a/packages/encryption/tsconfig.json b/packages/encryption/tsconfig.json new file mode 100644 index 000000000..f3dac14ef --- /dev/null +++ b/packages/encryption/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../node_modules/@standardnotes/config/src/tsconfig.json", + "compilerOptions": { + "skipLibCheck": true, + "rootDir": "./src", + "outDir": "./dist", + }, + "include": [ + "src/**/*" + ], + "references": [], + "exclude": ["**/*.spec.ts", "dist", "node_modules"] +} diff --git a/packages/mobile/package.json b/packages/mobile/package.json index ef388f135..83606cd83 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -36,6 +36,7 @@ "@react-navigation/native": "^6.0.10", "@react-navigation/stack": "^6.2.1", "@standardnotes/components-meta": "workspace:*", + "@standardnotes/encryption": "workspace:^", "@standardnotes/filepicker": "^1.16.23", "@standardnotes/icons": "workspace:*", "@standardnotes/react-native-aes": "^1.4.3", diff --git a/packages/mobile/src/Lib/ComponentManager.ts b/packages/mobile/src/Lib/ComponentManager.ts index 245c544ad..43e8418e7 100644 --- a/packages/mobile/src/Lib/ComponentManager.ts +++ b/packages/mobile/src/Lib/ComponentManager.ts @@ -1,10 +1,10 @@ import { MobileTheme } from '@Root/Style/MobileTheme' import { ComponentChecksumsType } from '@standardnotes/components-meta' import RawComponentChecksumsFile from '@standardnotes/components-meta/dist/zips/checksums.json' +import { EncryptionService } from '@standardnotes/encryption' import { FeatureDescription, FeatureIdentifier, GetFeatures } from '@standardnotes/features' import { ComponentMutator, - EncryptionService, isRightVersionGreaterThanLeft, PermissionDialog, SNApplication, diff --git a/packages/web/package.json b/packages/web/package.json index 2678e4f19..7fa483518 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -68,6 +68,7 @@ "@reach/listbox": "^0.16.2", "@reach/tooltip": "^0.16.2", "@reach/visually-hidden": "^0.16.0", + "@standardnotes/encryption": "workspace:*", "@standardnotes/filepicker": "1.16.23", "@standardnotes/icons": "workspace:*", "@standardnotes/services": "^1.13.23", diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/Backups/DataBackups.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/Backups/DataBackups.tsx index a387b2562..fe3ce6dcd 100644 --- a/packages/web/src/javascripts/Components/Preferences/Panes/Backups/DataBackups.tsx +++ b/packages/web/src/javascripts/Components/Preferences/Panes/Backups/DataBackups.tsx @@ -10,7 +10,7 @@ import { STRING_LOCAL_ENC_ENABLED, STRING_ENC_NOT_ENABLED, } from '@/Constants/Strings' -import { BackupFile } from '@standardnotes/snjs' +import { BackupFile } from '@standardnotes/encryption' import { ChangeEventHandler, MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react' import { WebApplication } from '@/Application/Application' import { ViewControllerManager } from '@/Services/ViewControllerManager' diff --git a/packages/web/src/javascripts/Services/ArchiveManager.ts b/packages/web/src/javascripts/Services/ArchiveManager.ts index f5d6500d7..ef426a049 100644 --- a/packages/web/src/javascripts/Services/ArchiveManager.ts +++ b/packages/web/src/javascripts/Services/ArchiveManager.ts @@ -2,11 +2,11 @@ import { WebApplication } from '@/Application/Application' import { parseFileName } from '@standardnotes/filepicker' import { ContentType, - BackupFile, BackupFileDecryptedContextualPayload, NoteContent, EncryptedItemInterface, } from '@standardnotes/snjs' +import { BackupFile } from '@standardnotes/encryption' function sanitizeFileName(name: string): string { return name.trim().replace(/[.\\/:"?*|<>]/g, '_') diff --git a/yarn.lock b/yarn.lock index bc5131707..185fd74e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6165,7 +6165,7 @@ __metadata: languageName: unknown linkType: soft -"@standardnotes/config@npm:^2.4.3": +"@standardnotes/config@npm:2.4.3, @standardnotes/config@npm:^2.4.3": version: 2.4.3 resolution: "@standardnotes/config@npm:2.4.3" dependencies: @@ -6326,27 +6326,26 @@ __metadata: languageName: node linkType: hard -"@standardnotes/encryption@npm:^1.8.22": - version: 1.8.22 - resolution: "@standardnotes/encryption@npm:1.8.22" - dependencies: - "@standardnotes/models": ^1.11.12 - "@standardnotes/responses": ^1.6.38 - "@standardnotes/services": ^1.13.22 - checksum: 7a571c4b257b7469054b37930b4157fa786b81558d0d7887fc5db0527c7eb5acd1d7ecdb90509d50652440017e708eb87d8820cf0df52b3c7a8628940d0e0e72 - languageName: node - linkType: hard - -"@standardnotes/encryption@npm:^1.8.23": - version: 1.8.23 - resolution: "@standardnotes/encryption@npm:1.8.23" +"@standardnotes/encryption@^1.8.22, @standardnotes/encryption@^1.8.23, @standardnotes/encryption@workspace:*, @standardnotes/encryption@workspace:^, @standardnotes/encryption@workspace:packages/encryption": + version: 0.0.0-use.local + resolution: "@standardnotes/encryption@workspace:packages/encryption" dependencies: + "@standardnotes/common": ^1.23.1 + "@standardnotes/config": 2.4.3 "@standardnotes/models": ^1.11.13 "@standardnotes/responses": ^1.6.39 "@standardnotes/services": ^1.13.23 - checksum: b86df01dc7d76eb170bd5d7cbe17b04d4c77ff657fda19cfc66a4e72d143016407ffc1434063e19d7f80a7a953df113a60712cba4a22be4a7ab7f125311a77e5 - languageName: node - linkType: hard + "@standardnotes/sncrypto-common": ^1.9.0 + "@standardnotes/utils": ^1.6.12 + "@types/jest": ^27.4.1 + "@types/node": ^18.0.0 + "@typescript-eslint/eslint-plugin": ^5.30.0 + eslint-plugin-prettier: ^4.2.1 + jest: ^27.5.1 + reflect-metadata: ^0.1.13 + ts-jest: ^27.1.3 + languageName: unknown + linkType: soft "@standardnotes/eslint-config-extensions@npm:1.0.1": version: 1.0.1 @@ -6721,6 +6720,7 @@ __metadata: "@react-navigation/stack": ^6.2.1 "@standardnotes/components-meta": "workspace:*" "@standardnotes/config": ^2.4.3 + "@standardnotes/encryption": "workspace:^" "@standardnotes/filepicker": ^1.16.23 "@standardnotes/icons": "workspace:*" "@standardnotes/react-native-aes": ^1.4.3 @@ -7215,6 +7215,7 @@ __metadata: "@reach/listbox": ^0.16.2 "@reach/tooltip": ^0.16.2 "@reach/visually-hidden": ^0.16.0 + "@standardnotes/encryption": "workspace:*" "@standardnotes/filepicker": 1.16.23 "@standardnotes/icons": "workspace:*" "@standardnotes/services": ^1.13.23 @@ -8223,6 +8224,13 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^18.0.0": + version: 18.0.1 + resolution: "@types/node@npm:18.0.1" + checksum: be14b251c54cc2b4ca78ac6eadf2fe5e831e487f2e17848f21d576295945b538271dcc674d0bba582b3f8d95b84f6826e99b6ba4710c76f165a8bdd4d4f0618e + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.0": version: 2.4.1 resolution: "@types/normalize-package-data@npm:2.4.1" @@ -8910,6 +8918,29 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/eslint-plugin@npm:^5.30.0": + version: 5.30.4 + resolution: "@typescript-eslint/eslint-plugin@npm:5.30.4" + dependencies: + "@typescript-eslint/scope-manager": 5.30.4 + "@typescript-eslint/type-utils": 5.30.4 + "@typescript-eslint/utils": 5.30.4 + debug: ^4.3.4 + functional-red-black-tree: ^1.0.1 + ignore: ^5.2.0 + regexpp: ^3.2.0 + semver: ^7.3.7 + tsutils: ^3.21.0 + peerDependencies: + "@typescript-eslint/parser": ^5.0.0 + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + typescript: + optional: true + checksum: 9b9290448b3009b93dc9bbc263049103f48c006d395351695486f7ab156f24b3f3e9e83a9f68a8cf73afc036c2d1092005446085f171afc9cdcb0b1b475443e3 + languageName: node + linkType: hard + "@typescript-eslint/experimental-utils@npm:^5.0.0": version: 5.30.0 resolution: "@typescript-eslint/experimental-utils@npm:5.30.0" @@ -8948,6 +8979,16 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/scope-manager@npm:5.30.4": + version: 5.30.4 + resolution: "@typescript-eslint/scope-manager@npm:5.30.4" + dependencies: + "@typescript-eslint/types": 5.30.4 + "@typescript-eslint/visitor-keys": 5.30.4 + checksum: 3da442dc113ee821c6b1c4510ee4cfd6f6f34838587785b7c486d262af913dca66229a47ebb9a63ad605f8edbe57a8387be24c817c8091783b57c33b7862cbcc + languageName: node + linkType: hard + "@typescript-eslint/type-utils@npm:5.30.0": version: 5.30.0 resolution: "@typescript-eslint/type-utils@npm:5.30.0" @@ -8964,6 +9005,22 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/type-utils@npm:5.30.4": + version: 5.30.4 + resolution: "@typescript-eslint/type-utils@npm:5.30.4" + dependencies: + "@typescript-eslint/utils": 5.30.4 + debug: ^4.3.4 + tsutils: ^3.21.0 + peerDependencies: + eslint: "*" + peerDependenciesMeta: + typescript: + optional: true + checksum: 552eb1a5b11787d3b98dc454a80153b05bcb6d58aeb97c861d6b006f3eb6af95d117a3f9a679b41a8b6d58ac0dceaaeafd23ce28d83881a363e51bbc1a088936 + languageName: node + linkType: hard + "@typescript-eslint/types@npm:5.30.0": version: 5.30.0 resolution: "@typescript-eslint/types@npm:5.30.0" @@ -8971,6 +9028,13 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/types@npm:5.30.4": + version: 5.30.4 + resolution: "@typescript-eslint/types@npm:5.30.4" + checksum: 06181c33551850492ccfd48232f93083c6cf9205d26b26fe6e356b7a4ebb08beffd89ae3b84011da94ffd0e6948422d91d94df7005edeca1c8348117d4776715 + languageName: node + linkType: hard + "@typescript-eslint/typescript-estree@npm:5.30.0": version: 5.30.0 resolution: "@typescript-eslint/typescript-estree@npm:5.30.0" @@ -8989,6 +9053,24 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/typescript-estree@npm:5.30.4": + version: 5.30.4 + resolution: "@typescript-eslint/typescript-estree@npm:5.30.4" + dependencies: + "@typescript-eslint/types": 5.30.4 + "@typescript-eslint/visitor-keys": 5.30.4 + debug: ^4.3.4 + globby: ^11.1.0 + is-glob: ^4.0.3 + semver: ^7.3.7 + tsutils: ^3.21.0 + peerDependenciesMeta: + typescript: + optional: true + checksum: 1aaa414993a4e35927e93f929d34a6e07cb8ec46c4ea9a58f018c36ff1fc3027ca5007e6abe922c5869557cd2d7f319e8c57cccd618517781979e669d3b704d0 + languageName: node + linkType: hard + "@typescript-eslint/utils@npm:5.30.0, @typescript-eslint/utils@npm:^5.13.0": version: 5.30.0 resolution: "@typescript-eslint/utils@npm:5.30.0" @@ -9005,6 +9087,22 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/utils@npm:5.30.4": + version: 5.30.4 + resolution: "@typescript-eslint/utils@npm:5.30.4" + dependencies: + "@types/json-schema": ^7.0.9 + "@typescript-eslint/scope-manager": 5.30.4 + "@typescript-eslint/types": 5.30.4 + "@typescript-eslint/typescript-estree": 5.30.4 + eslint-scope: ^5.1.1 + eslint-utils: ^3.0.0 + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + checksum: 0f680d366701c6ca5a4e1fc53247876e6f6acaee498400d32a7cb767e6187d0d75bc4488350cf6dc6e7c373ea023d62e395ea14ba56ad246b458d6853b213782 + languageName: node + linkType: hard + "@typescript-eslint/visitor-keys@npm:5.30.0": version: 5.30.0 resolution: "@typescript-eslint/visitor-keys@npm:5.30.0" @@ -9015,6 +9113,16 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/visitor-keys@npm:5.30.4": + version: 5.30.4 + resolution: "@typescript-eslint/visitor-keys@npm:5.30.4" + dependencies: + "@typescript-eslint/types": 5.30.4 + eslint-visitor-keys: ^3.3.0 + checksum: ec39680a89b058e8350adc084c2a957e83161e87ac9732c9fef8fd3045ce5004e059b2ddb0c366192806e3998bf3263c8bbb2cc74a4f2ad3313154ed35dd479a + languageName: node + linkType: hard + "@uiw/react-codemirror@npm:4.5.1": version: 4.5.1 resolution: "@uiw/react-codemirror@npm:4.5.1" @@ -17105,6 +17213,21 @@ __metadata: languageName: node linkType: hard +"eslint-plugin-prettier@npm:^4.2.1": + version: 4.2.1 + resolution: "eslint-plugin-prettier@npm:4.2.1" + dependencies: + prettier-linter-helpers: ^1.0.0 + peerDependencies: + eslint: ">=7.28.0" + prettier: ">=2.0.0" + peerDependenciesMeta: + eslint-config-prettier: + optional: true + checksum: b9e839d2334ad8ec7a5589c5cb0f219bded260839a857d7a486997f9870e95106aa59b8756ff3f37202085ebab658de382b0267cae44c3a7f0eb0bcc03a4f6d6 + languageName: node + linkType: hard + "eslint-plugin-promise@npm:^4.3.1": version: 4.3.1 resolution: "eslint-plugin-promise@npm:4.3.1" @@ -31903,6 +32026,13 @@ __metadata: languageName: node linkType: hard +"reflect-metadata@npm:^0.1.13": + version: 0.1.13 + resolution: "reflect-metadata@npm:0.1.13" + checksum: 798d379a7b6f6455501145419505c97dd11cbc23857a386add2b9ef15963ccf15a48d9d15507afe01d4cd74116df8a213247200bac00320bd7c11ddeaa5e8fb4 + languageName: node + linkType: hard + "refractor@npm:^4.0.0": version: 4.7.0 resolution: "refractor@npm:4.7.0" @@ -35978,7 +36108,7 @@ __metadata: languageName: node linkType: hard -"ts-jest@npm:^27.1.4": +"ts-jest@npm:^27.1.3, ts-jest@npm:^27.1.4": version: 27.1.5 resolution: "ts-jest@npm:27.1.5" dependencies: