From 8803f87994e2090251b5275422a69d48e912bba3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Apr 2024 11:09:00 -0400 Subject: [PATCH 01/18] [deps] Autofill: Update prettier-plugin-tailwindcss to v0.5.13 (#8558) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 15 ++++++++------- package.json | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 53687498534c..0fb4d3e36ade 100644 --- a/package-lock.json +++ b/package-lock.json @@ -161,7 +161,7 @@ "postcss": "8.4.35", "postcss-loader": "8.1.1", "prettier": "3.2.2", - "prettier-plugin-tailwindcss": "0.5.12", + "prettier-plugin-tailwindcss": "0.5.13", "process": "0.11.10", "react": "18.2.0", "react-dom": "18.2.0", @@ -31723,9 +31723,9 @@ } }, "node_modules/prettier-plugin-tailwindcss": { - "version": "0.5.12", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.5.12.tgz", - "integrity": "sha512-o74kiDBVE73oHW+pdkFSluHBL3cYEvru5YgEqNkBMFF7Cjv+w1vI565lTlfoJT4VLWDe0FMtZ7FkE/7a4pMXSQ==", + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.5.13.tgz", + "integrity": "sha512-2tPWHCFNC+WRjAC4SIWQNSOdcL1NNkydXim8w7TDqlZi+/ulZYz2OouAI6qMtkggnPt7lGamboj6LcTMwcCvoQ==", "dev": true, "engines": { "node": ">=14.21.3" @@ -31735,6 +31735,7 @@ "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig-melody": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", @@ -31760,6 +31761,9 @@ "@trivago/prettier-plugin-sort-imports": { "optional": true }, + "@zackad/prettier-plugin-twig-melody": { + "optional": true + }, "prettier-plugin-astro": { "optional": true }, @@ -31789,9 +31793,6 @@ }, "prettier-plugin-svelte": { "optional": true - }, - "prettier-plugin-twig-melody": { - "optional": true } } }, diff --git a/package.json b/package.json index 86026c6e93e3..9f85b4225167 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,7 @@ "postcss": "8.4.35", "postcss-loader": "8.1.1", "prettier": "3.2.2", - "prettier-plugin-tailwindcss": "0.5.12", + "prettier-plugin-tailwindcss": "0.5.13", "process": "0.11.10", "react": "18.2.0", "react-dom": "18.2.0", From 4db7cf915551b95851f7a7c928f2751544cfbee8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Apr 2024 11:10:11 -0400 Subject: [PATCH 02/18] [deps] Autofill: Update tldts to v6.1.16 (#8559) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/cli/package.json | 2 +- package-lock.json | 18 +++++++++--------- package.json | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 2de11df4e640..690842d831de 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -71,7 +71,7 @@ "papaparse": "5.4.1", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", - "tldts": "6.1.13", + "tldts": "6.1.16", "zxcvbn": "4.4.2" } } diff --git a/package-lock.json b/package-lock.json index 0fb4d3e36ade..96c2d6945919 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,7 +67,7 @@ "qrious": "4.0.2", "rxjs": "7.8.1", "tabbable": "6.2.0", - "tldts": "6.1.13", + "tldts": "6.1.16", "utf-8-validate": "6.0.3", "zone.js": "0.13.3", "zxcvbn": "4.4.2" @@ -224,7 +224,7 @@ "papaparse": "5.4.1", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", - "tldts": "6.1.13", + "tldts": "6.1.16", "zxcvbn": "4.4.2" }, "bin": { @@ -36529,20 +36529,20 @@ "dev": true }, "node_modules/tldts": { - "version": "6.1.13", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.13.tgz", - "integrity": "sha512-+GxHFKVHvUTg2ieNPTx3b/NpZbgJSTZEDdI4cJzTjVYDuxijeHi1tt7CHHsMjLqyc+T50VVgWs3LIb2LrXOzxw==", + "version": "6.1.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.16.tgz", + "integrity": "sha512-X6VrQzW4RymhI1kBRvrWzYlRLXTftZpi7/s/9ZlDILA04yM2lNX7mBvkzDib9L4uSymHt8mBbeaielZMdsAkfQ==", "dependencies": { - "tldts-core": "^6.1.13" + "tldts-core": "^6.1.16" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "6.1.13", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.13.tgz", - "integrity": "sha512-M1XP4D13YtXARKroULnLsKKuI1NCRAbJmUGGoXqWinajIDOhTeJf/trYUyBoLVx1/Nx1KBKxCrlW57ZW9cMHAA==" + "version": "6.1.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.16.tgz", + "integrity": "sha512-rxnuCux+zn3hMF57nBzr1m1qGZH7Od2ErbDZjVm04fk76cEynTg3zqvHjx5BsBl8lvRTjpzIhsEGMHDH/Hr2Vw==" }, "node_modules/tmp": { "version": "0.0.33", diff --git a/package.json b/package.json index 9f85b4225167..5a67feb253fc 100644 --- a/package.json +++ b/package.json @@ -200,7 +200,7 @@ "qrious": "4.0.2", "rxjs": "7.8.1", "tabbable": "6.2.0", - "tldts": "6.1.13", + "tldts": "6.1.16", "utf-8-validate": "6.0.3", "zone.js": "0.13.3", "zxcvbn": "4.4.2" From c3c895230fcae65d46c31eeb2282503789ebdd4d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Apr 2024 11:54:27 -0700 Subject: [PATCH 03/18] [deps] SM: Update typescript-eslint monorepo to v7 (#8116) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 156 +++++++++++++++++++++++----------------------- package.json | 4 +- 2 files changed, 80 insertions(+), 80 deletions(-) diff --git a/package-lock.json b/package-lock.json index 96c2d6945919..ef2390496752 100644 --- a/package-lock.json +++ b/package-lock.json @@ -117,8 +117,8 @@ "@types/react": "16.14.57", "@types/retry": "0.12.5", "@types/zxcvbn": "4.4.4", - "@typescript-eslint/eslint-plugin": "6.21.0", - "@typescript-eslint/parser": "6.21.0", + "@typescript-eslint/eslint-plugin": "7.4.0", + "@typescript-eslint/parser": "7.4.0", "@webcomponents/custom-elements": "1.6.0", "autoprefixer": "10.4.18", "base64-loader": "1.0.0", @@ -11982,16 +11982,16 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", - "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.4.0.tgz", + "integrity": "sha512-yHMQ/oFaM7HZdVrVm/M2WHaNPgyuJH4WelkSVEWSSsir34kxW2kDJCxlXRhhGWEsMN0WAW/vLpKfKVcm8k+MPw==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/type-utils": "6.21.0", - "@typescript-eslint/utils": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", + "@typescript-eslint/scope-manager": "7.4.0", + "@typescript-eslint/type-utils": "7.4.0", + "@typescript-eslint/utils": "7.4.0", + "@typescript-eslint/visitor-keys": "7.4.0", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.4", @@ -12000,15 +12000,15 @@ "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", - "eslint": "^7.0.0 || ^8.0.0" + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" }, "peerDependenciesMeta": { "typescript": { @@ -12017,16 +12017,16 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", - "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.4.0.tgz", + "integrity": "sha512-68VqENG5HK27ypafqLVs8qO+RkNc7TezCduYrx8YJpXq2QGZ30vmNZGJJJC48+MVn4G2dCV8m5ZTVnzRexTVtw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0" + "@typescript-eslint/types": "7.4.0", + "@typescript-eslint/visitor-keys": "7.4.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -12034,25 +12034,25 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", - "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.4.0.tgz", + "integrity": "sha512-247ETeHgr9WTRMqHbbQdzwzhuyaJ8dPTuyuUEMANqzMRB1rj/9qFIuIXK7l0FX9i9FXbHeBQl/4uz6mYuCE7Aw==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/typescript-estree": "7.4.0", + "@typescript-eslint/utils": "7.4.0", "debug": "^4.3.4", "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "eslint": "^8.56.0" }, "peerDependenciesMeta": { "typescript": { @@ -12061,12 +12061,12 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", - "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.4.0.tgz", + "integrity": "sha512-mjQopsbffzJskos5B4HmbsadSJQWaRK0UxqQ7GuNA9Ga4bEKeiO6b2DnB6cM6bpc8lemaPseh0H9B/wyg+J7rw==", "dev": true, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -12074,13 +12074,13 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", - "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.4.0.tgz", + "integrity": "sha512-A99j5AYoME/UBQ1ucEbbMEmGkN7SE0BvZFreSnTd1luq7yulcHdyGamZKizU7canpGDWGJ+Q6ZA9SyQobipePg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", + "@typescript-eslint/types": "7.4.0", + "@typescript-eslint/visitor-keys": "7.4.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -12089,7 +12089,7 @@ "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -12102,41 +12102,41 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", - "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.4.0.tgz", + "integrity": "sha512-NQt9QLM4Tt8qrlBVY9lkMYzfYtNz8/6qwZg8pI3cMGlPnj6mOpRxxAm7BMJN9K0AiY+1BwJ5lVC650YJqYOuNg==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.12", "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/scope-manager": "7.4.0", + "@typescript-eslint/types": "7.4.0", + "@typescript-eslint/typescript-estree": "7.4.0", "semver": "^7.5.4" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "eslint": "^8.56.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", - "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.4.0.tgz", + "integrity": "sha512-0zkC7YM0iX5Y41homUUeW1CHtZR01K3ybjM1l6QczoMuay0XKtrb93kv95AxUGwdjGr64nNqnOCwmEl616N8CA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/types": "7.4.0", "eslint-visitor-keys": "^3.4.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -12235,26 +12235,26 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", - "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.4.0.tgz", + "integrity": "sha512-ZvKHxHLusweEUVwrGRXXUVzFgnWhigo4JurEj0dGF1tbcGh6buL+ejDdjxOQxv6ytcY1uhun1p2sm8iWStlgLQ==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", + "@typescript-eslint/scope-manager": "7.4.0", + "@typescript-eslint/types": "7.4.0", + "@typescript-eslint/typescript-estree": "7.4.0", + "@typescript-eslint/visitor-keys": "7.4.0", "debug": "^4.3.4" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "eslint": "^8.56.0" }, "peerDependenciesMeta": { "typescript": { @@ -12263,16 +12263,16 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", - "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.4.0.tgz", + "integrity": "sha512-68VqENG5HK27ypafqLVs8qO+RkNc7TezCduYrx8YJpXq2QGZ30vmNZGJJJC48+MVn4G2dCV8m5ZTVnzRexTVtw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0" + "@typescript-eslint/types": "7.4.0", + "@typescript-eslint/visitor-keys": "7.4.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -12280,12 +12280,12 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", - "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.4.0.tgz", + "integrity": "sha512-mjQopsbffzJskos5B4HmbsadSJQWaRK0UxqQ7GuNA9Ga4bEKeiO6b2DnB6cM6bpc8lemaPseh0H9B/wyg+J7rw==", "dev": true, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -12293,13 +12293,13 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", - "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.4.0.tgz", + "integrity": "sha512-A99j5AYoME/UBQ1ucEbbMEmGkN7SE0BvZFreSnTd1luq7yulcHdyGamZKizU7canpGDWGJ+Q6ZA9SyQobipePg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", + "@typescript-eslint/types": "7.4.0", + "@typescript-eslint/visitor-keys": "7.4.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -12308,7 +12308,7 @@ "ts-api-utils": "^1.0.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", @@ -12321,16 +12321,16 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", - "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.4.0.tgz", + "integrity": "sha512-0zkC7YM0iX5Y41homUUeW1CHtZR01K3ybjM1l6QczoMuay0XKtrb93kv95AxUGwdjGr64nNqnOCwmEl616N8CA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/types": "7.4.0", "eslint-visitor-keys": "^3.4.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^18.18.0 || >=20.0.0" }, "funding": { "type": "opencollective", diff --git a/package.json b/package.json index 5a67feb253fc..88ba36e3c0f2 100644 --- a/package.json +++ b/package.json @@ -78,8 +78,8 @@ "@types/react": "16.14.57", "@types/retry": "0.12.5", "@types/zxcvbn": "4.4.4", - "@typescript-eslint/eslint-plugin": "6.21.0", - "@typescript-eslint/parser": "6.21.0", + "@typescript-eslint/eslint-plugin": "7.4.0", + "@typescript-eslint/parser": "7.4.0", "@webcomponents/custom-elements": "1.6.0", "autoprefixer": "10.4.18", "base64-loader": "1.0.0", From 136226b6beb0dc69ac7b46c9f5a02a6fbf184126 Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Mon, 1 Apr 2024 14:15:54 -0500 Subject: [PATCH 04/18] Observable auth statuses (#8537) * Observable has token * Allow access to user key state observable * Create observable auth status * Fix DI --- .../service-factories/auth-service.factory.ts | 5 +- .../browser/src/background/main.background.ts | 1 + apps/cli/src/bw.ts | 1 + .../src/services/jslib-services.module.ts | 1 + .../src/auth/abstractions/auth.service.ts | 7 + .../src/auth/abstractions/token.service.ts | 7 + .../src/auth/services/auth.service.spec.ts | 146 +++++++++++++++--- libs/common/src/auth/services/auth.service.ts | 41 ++++- .../src/auth/services/token.service.spec.ts | 56 +++++++ .../common/src/auth/services/token.service.ts | 11 +- .../platform/abstractions/crypto.service.ts | 8 + .../src/platform/services/crypto.service.ts | 4 + 12 files changed, 261 insertions(+), 27 deletions(-) diff --git a/apps/browser/src/auth/background/service-factories/auth-service.factory.ts b/apps/browser/src/auth/background/service-factories/auth-service.factory.ts index bc4e621bc6ef..f600efa18d5e 100644 --- a/apps/browser/src/auth/background/service-factories/auth-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/auth-service.factory.ts @@ -24,6 +24,7 @@ import { } from "../../../platform/background/service-factories/state-service.factory"; import { AccountServiceInitOptions, accountServiceFactory } from "./account-service.factory"; +import { TokenServiceInitOptions, tokenServiceFactory } from "./token-service.factory"; type AuthServiceFactoryOptions = FactoryOptions; @@ -32,7 +33,8 @@ export type AuthServiceInitOptions = AuthServiceFactoryOptions & MessagingServiceInitOptions & CryptoServiceInitOptions & ApiServiceInitOptions & - StateServiceInitOptions; + StateServiceInitOptions & + TokenServiceInitOptions; export function authServiceFactory( cache: { authService?: AbstractAuthService } & CachedServices, @@ -49,6 +51,7 @@ export function authServiceFactory( await cryptoServiceFactory(cache, opts), await apiServiceFactory(cache, opts), await stateServiceFactory(cache, opts), + await tokenServiceFactory(cache, opts), ), ); } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index ee17a7f1f06a..49b4b9624901 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -579,6 +579,7 @@ export default class MainBackground { this.cryptoService, this.apiService, this.stateService, + this.tokenService, ); this.billingAccountProfileStateService = new DefaultBillingAccountProfileStateService( diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index 7f23e6f2d0fa..d1105427f6b2 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -503,6 +503,7 @@ export class Main { this.cryptoService, this.apiService, this.stateService, + this.tokenService, ); this.configApiService = new ConfigApiService(this.apiService, this.tokenService); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index a31d5141c472..b08c53ec06f1 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -349,6 +349,7 @@ const safeProviders: SafeProvider[] = [ CryptoServiceAbstraction, ApiServiceAbstraction, StateServiceAbstraction, + TokenService, ], }), safeProvider({ diff --git a/libs/common/src/auth/abstractions/auth.service.ts b/libs/common/src/auth/abstractions/auth.service.ts index 9e4fd3cd0be5..de08dbd4e99d 100644 --- a/libs/common/src/auth/abstractions/auth.service.ts +++ b/libs/common/src/auth/abstractions/auth.service.ts @@ -1,10 +1,17 @@ import { Observable } from "rxjs"; +import { UserId } from "../../types/guid"; import { AuthenticationStatus } from "../enums/authentication-status"; export abstract class AuthService { /** Authentication status for the active user */ abstract activeAccountStatus$: Observable; + /** + * Returns an observable authentication status for the given user id. + * @note userId is a required parameter, null values will always return `AuthenticationStatus.LoggedOut` + * @param userId The user id to check for an access token. + */ + abstract authStatusFor$(userId: UserId): Observable; /** @deprecated use {@link activeAccountStatus$} instead */ abstract getAuthStatus: (userId?: string) => Promise; abstract logOut: (callback: () => void) => void; diff --git a/libs/common/src/auth/abstractions/token.service.ts b/libs/common/src/auth/abstractions/token.service.ts index 18366c5f1b32..75bb38388281 100644 --- a/libs/common/src/auth/abstractions/token.service.ts +++ b/libs/common/src/auth/abstractions/token.service.ts @@ -1,8 +1,15 @@ +import { Observable } from "rxjs"; + import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; import { UserId } from "../../types/guid"; import { DecodedAccessToken } from "../services/token.service"; export abstract class TokenService { + /** + * Returns an observable that emits a boolean indicating whether the user has an access token. + * @param userId The user id to check for an access token. + */ + abstract hasAccessToken$(userId: UserId): Observable; /** * Sets the access token, refresh token, API Key Client ID, and API Key Client Secret in memory or disk * based on the given vaultTimeoutAction and vaultTimeout and the derived access token user id. diff --git a/libs/common/src/auth/services/auth.service.spec.ts b/libs/common/src/auth/services/auth.service.spec.ts index dd4daf8cfa83..07e38def4b12 100644 --- a/libs/common/src/auth/services/auth.service.spec.ts +++ b/libs/common/src/auth/services/auth.service.spec.ts @@ -1,13 +1,21 @@ import { MockProxy, mock } from "jest-mock-extended"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, of } from "rxjs"; -import { FakeAccountService, mockAccountServiceWith } from "../../../spec"; +import { + FakeAccountService, + makeStaticByteArray, + mockAccountServiceWith, + trackEmissions, +} from "../../../spec"; import { ApiService } from "../../abstractions/api.service"; import { CryptoService } from "../../platform/abstractions/crypto.service"; import { MessagingService } from "../../platform/abstractions/messaging.service"; import { StateService } from "../../platform/abstractions/state.service"; import { Utils } from "../../platform/misc/utils"; +import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; import { UserId } from "../../types/guid"; +import { UserKey } from "../../types/key"; +import { TokenService } from "../abstractions/token.service"; import { AuthenticationStatus } from "../enums/authentication-status"; import { AuthService } from "./auth.service"; @@ -20,15 +28,18 @@ describe("AuthService", () => { let cryptoService: MockProxy; let apiService: MockProxy; let stateService: MockProxy; + let tokenService: MockProxy; const userId = Utils.newGuid() as UserId; + const userKey = new SymmetricCryptoKey(makeStaticByteArray(32) as Uint8Array) as UserKey; beforeEach(() => { accountService = mockAccountServiceWith(userId); - messagingService = mock(); - cryptoService = mock(); - apiService = mock(); - stateService = mock(); + messagingService = mock(); + cryptoService = mock(); + apiService = mock(); + stateService = mock(); + tokenService = mock(); sut = new AuthService( accountService, @@ -36,26 +47,115 @@ describe("AuthService", () => { cryptoService, apiService, stateService, + tokenService, ); }); describe("activeAccountStatus$", () => { - test.each([ - AuthenticationStatus.LoggedOut, - AuthenticationStatus.Locked, - AuthenticationStatus.Unlocked, - ])( - `should emit %p when activeAccount$ emits an account with %p auth status`, - async (status) => { - accountService.activeAccountSubject.next({ - id: userId, - email: "email", - name: "name", - status, - }); - - expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual(status); - }, - ); + const accountInfo = { + status: AuthenticationStatus.Unlocked, + id: userId, + email: "email", + name: "name", + }; + + beforeEach(() => { + accountService.activeAccountSubject.next(accountInfo); + tokenService.hasAccessToken$.mockReturnValue(of(true)); + cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined)); + }); + + it("emits LoggedOut when there is no active account", async () => { + accountService.activeAccountSubject.next(undefined); + + expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual( + AuthenticationStatus.LoggedOut, + ); + }); + + it("emits LoggedOut when there is no access token", async () => { + tokenService.hasAccessToken$.mockReturnValue(of(false)); + + expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual( + AuthenticationStatus.LoggedOut, + ); + }); + + it("emits LoggedOut when there is no access token but has a user key", async () => { + tokenService.hasAccessToken$.mockReturnValue(of(false)); + cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(userKey)); + + expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual( + AuthenticationStatus.LoggedOut, + ); + }); + + it("emits Locked when there is an access token and no user key", async () => { + tokenService.hasAccessToken$.mockReturnValue(of(true)); + cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined)); + + expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual(AuthenticationStatus.Locked); + }); + + it("emits Unlocked when there is an access token and user key", async () => { + tokenService.hasAccessToken$.mockReturnValue(of(true)); + cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(userKey)); + + expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual(AuthenticationStatus.Unlocked); + }); + + it("follows the current active user", async () => { + const accountInfo2 = { + status: AuthenticationStatus.Unlocked, + id: Utils.newGuid() as UserId, + email: "email2", + name: "name2", + }; + + const emissions = trackEmissions(sut.activeAccountStatus$); + + tokenService.hasAccessToken$.mockReturnValue(of(true)); + cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(userKey)); + accountService.activeAccountSubject.next(accountInfo2); + + expect(emissions).toEqual([AuthenticationStatus.Locked, AuthenticationStatus.Unlocked]); + }); + }); + + describe("authStatusFor$", () => { + beforeEach(() => { + tokenService.hasAccessToken$.mockReturnValue(of(true)); + cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined)); + }); + + it("emits LoggedOut when userId is null", async () => { + expect(await firstValueFrom(sut.authStatusFor$(null))).toEqual( + AuthenticationStatus.LoggedOut, + ); + }); + + it("emits LoggedOut when there is no access token", async () => { + tokenService.hasAccessToken$.mockReturnValue(of(false)); + + expect(await firstValueFrom(sut.authStatusFor$(userId))).toEqual( + AuthenticationStatus.LoggedOut, + ); + }); + + it("emits Locked when there is an access token and no user key", async () => { + tokenService.hasAccessToken$.mockReturnValue(of(true)); + cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined)); + + expect(await firstValueFrom(sut.authStatusFor$(userId))).toEqual(AuthenticationStatus.Locked); + }); + + it("emits Unlocked when there is an access token and user key", async () => { + tokenService.hasAccessToken$.mockReturnValue(of(true)); + cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(userKey)); + + expect(await firstValueFrom(sut.authStatusFor$(userId))).toEqual( + AuthenticationStatus.Unlocked, + ); + }); }); }); diff --git a/libs/common/src/auth/services/auth.service.ts b/libs/common/src/auth/services/auth.service.ts index ae5dd30a3645..de5eb66c0616 100644 --- a/libs/common/src/auth/services/auth.service.ts +++ b/libs/common/src/auth/services/auth.service.ts @@ -1,12 +1,22 @@ -import { Observable, distinctUntilChanged, map, shareReplay } from "rxjs"; +import { + Observable, + combineLatest, + distinctUntilChanged, + map, + of, + shareReplay, + switchMap, +} from "rxjs"; import { ApiService } from "../../abstractions/api.service"; import { CryptoService } from "../../platform/abstractions/crypto.service"; import { MessagingService } from "../../platform/abstractions/messaging.service"; import { StateService } from "../../platform/abstractions/state.service"; import { KeySuffixOptions } from "../../platform/enums"; +import { UserId } from "../../types/guid"; import { AccountService } from "../abstractions/account.service"; import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service"; +import { TokenService } from "../abstractions/token.service"; import { AuthenticationStatus } from "../enums/authentication-status"; export class AuthService implements AuthServiceAbstraction { @@ -18,9 +28,36 @@ export class AuthService implements AuthServiceAbstraction { protected cryptoService: CryptoService, protected apiService: ApiService, protected stateService: StateService, + private tokenService: TokenService, ) { this.activeAccountStatus$ = this.accountService.activeAccount$.pipe( - map((account) => account.status), + map((account) => account?.id), + switchMap((userId) => { + return this.authStatusFor$(userId); + }), + ); + } + + authStatusFor$(userId: UserId): Observable { + if (userId == null) { + return of(AuthenticationStatus.LoggedOut); + } + + return combineLatest([ + this.cryptoService.getInMemoryUserKeyFor$(userId), + this.tokenService.hasAccessToken$(userId), + ]).pipe( + map(([userKey, hasAccessToken]) => { + if (!hasAccessToken) { + return AuthenticationStatus.LoggedOut; + } + + if (!userKey) { + return AuthenticationStatus.Locked; + } + + return AuthenticationStatus.Unlocked; + }), distinctUntilChanged(), shareReplay({ bufferSize: 1, refCount: false }), ); diff --git a/libs/common/src/auth/services/token.service.spec.ts b/libs/common/src/auth/services/token.service.spec.ts index 8e8ed0885322..c40926320998 100644 --- a/libs/common/src/auth/services/token.service.spec.ts +++ b/libs/common/src/auth/services/token.service.spec.ts @@ -1,4 +1,5 @@ import { MockProxy, mock } from "jest-mock-extended"; +import { firstValueFrom } from "rxjs"; import { FakeSingleUserStateProvider, FakeGlobalStateProvider } from "../../../spec"; import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; @@ -104,6 +105,61 @@ describe("TokenService", () => { const accessTokenKeyPartialSecureStorageKey = `_accessTokenKey`; const accessTokenKeySecureStorageKey = `${userIdFromAccessToken}${accessTokenKeyPartialSecureStorageKey}`; + describe("hasAccessToken$", () => { + it("returns true when an access token exists in memory", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // Act + const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken)); + + // Assert + expect(result).toEqual(true); + }); + + it("returns true when an access token exists in disk", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) + .stateSubject.next([userIdFromAccessToken, undefined]); + + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, accessTokenJwt]); + + // Act + const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken)); + + // Assert + expect(result).toEqual(true); + }); + + it("returns true when an access token exists in secure storage", async () => { + // Arrange + singleUserStateProvider + .getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK) + .stateSubject.next([userIdFromAccessToken, "encryptedAccessToken"]); + + secureStorageService.get.mockResolvedValue(accessTokenKeyB64); + + // Act + const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken)); + + // Assert + expect(result).toEqual(true); + }); + + it("should return false if no access token exists in memory, disk, or secure storage", async () => { + // Act + const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken)); + + // Assert + expect(result).toEqual(false); + }); + }); + describe("setAccessToken", () => { it("should throw an error if the access token is null", async () => { // Act diff --git a/libs/common/src/auth/services/token.service.ts b/libs/common/src/auth/services/token.service.ts index dd011eb40bc8..fb13c218705a 100644 --- a/libs/common/src/auth/services/token.service.ts +++ b/libs/common/src/auth/services/token.service.ts @@ -1,4 +1,4 @@ -import { firstValueFrom } from "rxjs"; +import { Observable, combineLatest, firstValueFrom, map } from "rxjs"; import { Opaque } from "type-fest"; import { decodeJwtTokenToJson } from "@bitwarden/auth/common"; @@ -135,6 +135,15 @@ export class TokenService implements TokenServiceAbstraction { this.initializeState(); } + hasAccessToken$(userId: UserId): Observable { + // FIXME Once once vault timeout action is observable, we can use it to determine storage location + // and avoid the need to check both disk and memory. + return combineLatest([ + this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).state$, + this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).state$, + ]).pipe(map(([disk, memory]) => Boolean(disk || memory))); + } + // pivoting to an approach where we create a symmetric key we store in secure storage // which is used to protect the data before persisting to disk. // We will also use the same symmetric key to decrypt the data when reading from disk. diff --git a/libs/common/src/platform/abstractions/crypto.service.ts b/libs/common/src/platform/abstractions/crypto.service.ts index 44ff52168091..85b2bfe82e72 100644 --- a/libs/common/src/platform/abstractions/crypto.service.ts +++ b/libs/common/src/platform/abstractions/crypto.service.ts @@ -13,6 +13,14 @@ import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; export abstract class CryptoService { abstract activeUserKey$: Observable; + + /** + * Returns the an observable key for the given user id. + * + * @note this observable represents only user keys stored in memory. A null value does not indicate that we cannot load a user key from storage. + * @param userId The desired user + */ + abstract getInMemoryUserKeyFor$(userId: UserId): Observable; /** * Sets the provided user key and stores * any other necessary versions (such as auto, biometrics, diff --git a/libs/common/src/platform/services/crypto.service.ts b/libs/common/src/platform/services/crypto.service.ts index fbb6a8529370..dd3c49747011 100644 --- a/libs/common/src/platform/services/crypto.service.ts +++ b/libs/common/src/platform/services/crypto.service.ts @@ -160,6 +160,10 @@ export class CryptoService implements CryptoServiceAbstraction { await this.setUserKey(key); } + getInMemoryUserKeyFor$(userId: UserId): Observable { + return this.stateProvider.getUserState$(USER_KEY, userId); + } + async getUserKey(userId?: UserId): Promise { let userKey = await firstValueFrom(this.stateProvider.getUserState$(USER_KEY, userId)); if (userKey) { From 45f9f5695ea26e5ce1cce313c8eccc2063289190 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Mon, 1 Apr 2024 14:29:04 -0500 Subject: [PATCH 05/18] Add Custom `ErrorHandler` (#8543) --- .../src/platform/services/logging-error-handler.ts | 14 ++++++++++++++ libs/angular/src/services/jslib-services.module.ts | 8 +++++++- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 libs/angular/src/platform/services/logging-error-handler.ts diff --git a/libs/angular/src/platform/services/logging-error-handler.ts b/libs/angular/src/platform/services/logging-error-handler.ts new file mode 100644 index 000000000000..81cd537e7f13 --- /dev/null +++ b/libs/angular/src/platform/services/logging-error-handler.ts @@ -0,0 +1,14 @@ +import { ErrorHandler, Injectable } from "@angular/core"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + +@Injectable() +export class LoggingErrorHandler extends ErrorHandler { + constructor(private readonly logService: LogService) { + super(); + } + + override handleError(error: any): void { + this.logService.error(error); + } +} diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index b08c53ec06f1..c9a39eed0ae8 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -1,4 +1,4 @@ -import { LOCALE_ID, NgModule } from "@angular/core"; +import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core"; import { AuthRequestServiceAbstraction, @@ -238,6 +238,7 @@ import { UnauthGuard } from "../auth/guards/unauth.guard"; import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service"; import { BroadcasterService } from "../platform/services/broadcaster.service"; import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service"; +import { LoggingErrorHandler } from "../platform/services/logging-error-handler"; import { AngularThemingService } from "../platform/services/theming/angular-theming.service"; import { AbstractThemingService } from "../platform/services/theming/theming.service.abstraction"; import { safeProvider, SafeProvider } from "../platform/utils/safe-provider"; @@ -1070,6 +1071,11 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultOrganizationManagementPreferencesService, deps: [StateProvider], }), + safeProvider({ + provide: ErrorHandler, + useClass: LoggingErrorHandler, + deps: [LogService], + }), ]; function encryptServiceFactory( From bd7c10705d482a04415ea6114325c5eefbf12827 Mon Sep 17 00:00:00 2001 From: Jake Fink Date: Mon, 1 Apr 2024 15:32:11 -0400 Subject: [PATCH 06/18] add clarification around null in state provider (#8567) * add clarification around null in state provider * Update libs/common/src/platform/state/user-state.ts Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> --------- Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> --- libs/common/src/platform/state/user-state.ts | 21 ++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/libs/common/src/platform/state/user-state.ts b/libs/common/src/platform/state/user-state.ts index dc994cf9fdf9..44bc8732544b 100644 --- a/libs/common/src/platform/state/user-state.ts +++ b/libs/common/src/platform/state/user-state.ts @@ -6,24 +6,25 @@ import { StateUpdateOptions } from "./state-update-options"; export type CombinedState = readonly [userId: UserId, state: T]; -/** - * A helper object for interacting with state that is scoped to a specific user. - */ +/** A helper object for interacting with state that is scoped to a specific user. */ export interface UserState { - /** - * Emits a stream of data. - */ - readonly state$: Observable; + /** Emits a stream of data. Emits null if the user does not have specified state. */ + readonly state$: Observable; - /** - * Emits a stream of data alongside the user id the data corresponds to. - */ + /** Emits a stream of tuples, with the first element being a user id and the second element being the data for that user. */ readonly combinedState$: Observable>; } export const activeMarker: unique symbol = Symbol("active"); export interface ActiveUserState extends UserState { readonly [activeMarker]: true; + + /** + * Emits a stream of data. Emits null if the user does not have specified state. + * Note: Will not emit if there is no active user. + */ + readonly state$: Observable; + /** * Updates backing stores for the active user. * @param configureState function that takes the current state and returns the new state From 94843bdd8b1101faddf0c385db827b77d05294c8 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Mon, 1 Apr 2024 14:36:39 -0500 Subject: [PATCH 07/18] [PM-5956] Delete Unused State (#8439) * Delete Unused State * Delete One More * Add Migration to Delete InstalledVersion * Update Error --- apps/cli/src/bw.ts | 6 --- apps/desktop/src/app/services/init.service.ts | 12 ----- .../platform/abstractions/state.service.ts | 6 --- .../src/platform/models/domain/account.ts | 10 ---- .../platform/models/domain/global-state.ts | 8 --- .../src/platform/services/state.service.ts | 53 ------------------- libs/common/src/state-migrations/migrate.ts | 6 ++- .../52-delete-installed-version.spec.ts | 35 ++++++++++++ .../migrations/52-delete-installed-version.ts | 19 +++++++ 9 files changed, 58 insertions(+), 97 deletions(-) create mode 100644 libs/common/src/state-migrations/migrations/52-delete-installed-version.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/52-delete-installed-version.ts diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index d1105427f6b2..bba381b84a30 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -713,12 +713,6 @@ export class Main { this.containerService.attachToGlobal(global); await this.i18nService.init(); this.twoFactorService.init(); - - const installedVersion = await this.stateService.getInstalledVersion(); - const currentVersion = await this.platformUtilsService.getApplicationVersion(); - if (installedVersion == null || installedVersion !== currentVersion) { - await this.stateService.setInstalledVersion(currentVersion); - } } } diff --git a/apps/desktop/src/app/services/init.service.ts b/apps/desktop/src/app/services/init.service.ts index bb7d4e7b5215..d1a83d468c13 100644 --- a/apps/desktop/src/app/services/init.service.ts +++ b/apps/desktop/src/app/services/init.service.ts @@ -53,18 +53,6 @@ export class InitService { const htmlEl = this.win.document.documentElement; htmlEl.classList.add("os_" + this.platformUtilsService.getDeviceString()); this.themingService.applyThemeChangesTo(this.document); - let installAction = null; - const installedVersion = await this.stateService.getInstalledVersion(); - const currentVersion = await this.platformUtilsService.getApplicationVersion(); - if (installedVersion == null) { - installAction = "install"; - } else if (installedVersion !== currentVersion) { - installAction = "update"; - } - - if (installAction != null) { - await this.stateService.setInstalledVersion(currentVersion); - } const containerService = new ContainerService(this.cryptoService, this.encryptService); containerService.attachToGlobal(this.win); diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 79dc83868e4a..ab8b5489511f 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -50,8 +50,6 @@ export abstract class StateService { getAddEditCipherInfo: (options?: StorageOptions) => Promise; setAddEditCipherInfo: (value: AddEditCipherInfo, options?: StorageOptions) => Promise; - getBiometricFingerprintValidated: (options?: StorageOptions) => Promise; - setBiometricFingerprintValidated: (value: boolean, options?: StorageOptions) => Promise; /** * Gets the user's master key */ @@ -161,8 +159,6 @@ export abstract class StateService { * @deprecated Do not call this directly, use SendService */ setDecryptedSends: (value: SendView[], options?: StorageOptions) => Promise; - getDisableGa: (options?: StorageOptions) => Promise; - setDisableGa: (value: boolean, options?: StorageOptions) => Promise; getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise; setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise; getDeviceKey: (options?: StorageOptions) => Promise; @@ -220,8 +216,6 @@ export abstract class StateService { value: ForceSetPasswordReason, options?: StorageOptions, ) => Promise; - getInstalledVersion: (options?: StorageOptions) => Promise; - setInstalledVersion: (value: string, options?: StorageOptions) => Promise; getIsAuthenticated: (options?: StorageOptions) => Promise; getKdfConfig: (options?: StorageOptions) => Promise; setKdfConfig: (kdfConfig: KdfConfig, options?: StorageOptions) => Promise; diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index 01660006c0dd..61bb3eeac5f6 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -65,13 +65,6 @@ export class DataEncryptionPair { decrypted?: TDecrypted[]; } -// This is a temporary structure to handle migrated `DataEncryptionPair` to -// avoid needing a data migration at this stage. It should be replaced with -// proper data migrations when `DataEncryptionPair` is deprecated. -export class TemporaryDataEncryption { - encrypted?: { [id: string]: TEncrypted }; -} - export class AccountData { ciphers?: DataEncryptionPair = new DataEncryptionPair< CipherData, @@ -182,8 +175,6 @@ export class AccountProfile { export class AccountSettings { defaultUriMatch?: UriMatchStrategySetting; - disableGa?: boolean; - enableBiometric?: boolean; minimizeOnCopyToClipboard?: boolean; passwordGenerationOptions?: PasswordGeneratorOptions; usernameGenerationOptions?: UsernameGeneratorOptions; @@ -194,7 +185,6 @@ export class AccountSettings { vaultTimeout?: number; vaultTimeoutAction?: string = "lock"; approveLoginRequests?: boolean; - avatarColor?: string; trustDeviceChoiceForDecryption?: boolean; /** @deprecated July 2023, left for migration purposes*/ diff --git a/libs/common/src/platform/models/domain/global-state.ts b/libs/common/src/platform/models/domain/global-state.ts index b0a59e4617fe..cb9e3f71b34d 100644 --- a/libs/common/src/platform/models/domain/global-state.ts +++ b/libs/common/src/platform/models/domain/global-state.ts @@ -1,15 +1,7 @@ -import { ThemeType } from "../../enums"; - export class GlobalState { - installedVersion?: string; organizationInvitation?: any; - theme?: ThemeType = ThemeType.System; - twoFactorToken?: string; - biometricFingerprintValidated?: boolean; vaultTimeout?: number; vaultTimeoutAction?: string; - loginRedirect?: any; - mainWindowSize?: number; enableBrowserIntegration?: boolean; enableBrowserIntegrationFingerprint?: boolean; deepLinkRedirectUrl?: string; diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index c0b2a8fa2e7f..0e524b6c4b90 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -275,24 +275,6 @@ export class StateService< ); } - async getBiometricFingerprintValidated(options?: StorageOptions): Promise { - return ( - (await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) - ?.biometricFingerprintValidated ?? false - ); - } - - async setBiometricFingerprintValidated(value: boolean, options?: StorageOptions): Promise { - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - globals.biometricFingerprintValidated = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - /** * @deprecated Do not save the Master Key. Use the User Symmetric Key instead */ @@ -650,24 +632,6 @@ export class StateService< ); } - async getDisableGa(options?: StorageOptions): Promise { - return ( - (await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) - ?.settings?.disableGa ?? false - ); - } - - async setDisableGa(value: boolean, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.settings.disableGa = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getDuckDuckGoSharedKey(options?: StorageOptions): Promise { options = this.reconcileOptions(options, await this.defaultSecureStorageOptions()); if (options?.userId == null) { @@ -982,23 +946,6 @@ export class StateService< ); } - async getInstalledVersion(options?: StorageOptions): Promise { - return ( - await this.getGlobals(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.installedVersion; - } - - async setInstalledVersion(value: string, options?: StorageOptions): Promise { - const globals = await this.getGlobals( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - globals.installedVersion = value; - await this.saveGlobals( - globals, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getIsAuthenticated(options?: StorageOptions): Promise { return ( (await this.tokenService.getAccessToken(options?.userId as UserId)) != null && diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index 5222ee7ad7dd..0758d49f5992 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -48,6 +48,7 @@ import { AccountServerConfigMigrator } from "./migrations/49-move-account-server import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org-keys"; import { KeyConnectorMigrator } from "./migrations/50-move-key-connector-to-state-provider"; import { RememberedEmailMigrator } from "./migrations/51-move-remembered-email-to-state-providers"; +import { DeleteInstalledVersion } from "./migrations/52-delete-installed-version"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; import { MoveStateVersionMigrator } from "./migrations/8-move-state-version"; @@ -55,7 +56,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 3; -export const CURRENT_VERSION = 51; +export const CURRENT_VERSION = 52; export type MinVersion = typeof MIN_VERSION; @@ -109,7 +110,8 @@ export function createMigrationBuilder() { .with(MoveDdgToStateProviderMigrator, 47, 48) .with(AccountServerConfigMigrator, 48, 49) .with(KeyConnectorMigrator, 49, 50) - .with(RememberedEmailMigrator, 50, CURRENT_VERSION); + .with(RememberedEmailMigrator, 50, 51) + .with(DeleteInstalledVersion, 51, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/52-delete-installed-version.spec.ts b/libs/common/src/state-migrations/migrations/52-delete-installed-version.spec.ts new file mode 100644 index 000000000000..752f1297ff77 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/52-delete-installed-version.spec.ts @@ -0,0 +1,35 @@ +import { runMigrator } from "../migration-helper.spec"; + +import { DeleteInstalledVersion } from "./52-delete-installed-version"; + +describe("DeleteInstalledVersion", () => { + const sut = new DeleteInstalledVersion(51, 52); + + describe("migrate", () => { + it("can delete data if there", async () => { + const output = await runMigrator(sut, { + authenticatedAccounts: ["user1"], + global: { + installedVersion: "2024.1.1", + }, + }); + + expect(output).toEqual({ + authenticatedAccounts: ["user1"], + global: {}, + }); + }); + + it("will run if installed version is not there", async () => { + const output = await runMigrator(sut, { + authenticatedAccounts: ["user1"], + global: {}, + }); + + expect(output).toEqual({ + authenticatedAccounts: ["user1"], + global: {}, + }); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/52-delete-installed-version.ts b/libs/common/src/state-migrations/migrations/52-delete-installed-version.ts new file mode 100644 index 000000000000..7eea0e587c98 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/52-delete-installed-version.ts @@ -0,0 +1,19 @@ +import { MigrationHelper } from "../migration-helper"; +import { IRREVERSIBLE, Migrator } from "../migrator"; + +type ExpectedGlobal = { + installedVersion?: string; +}; + +export class DeleteInstalledVersion extends Migrator<51, 52> { + async migrate(helper: MigrationHelper): Promise { + const legacyGlobal = await helper.get("global"); + if (legacyGlobal?.installedVersion != null) { + delete legacyGlobal.installedVersion; + await helper.set("global", legacyGlobal); + } + } + rollback(helper: MigrationHelper): Promise { + throw IRREVERSIBLE; + } +} From c202c93378f1d48e15767d74e902e4888210cf26 Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Mon, 1 Apr 2024 16:02:58 -0400 Subject: [PATCH 08/18] Auth/PM-5268 - DeviceTrustCryptoService state provider migration (#7882) * PM-5268 - Add DEVICE_TRUST_DISK to state definitions * PM-5268 - DeviceTrustCryptoService - Get most of state provider refactor done - WIP - commented out stuff for now. * PM-5268 - DeviceTrustCryptoServiceStateProviderMigrator - WIP - got first draft of migrator in place and working on tests. Rollback tests are failing for some reason TBD. * PM-5268 - more WIP on device trust crypto service migrator tests * PM-5268 - DeviceTrustCryptoServiceStateProviderMigrator - Refactor based on call with platform * PM-5268 - DeviceTrustCryptoServiceStateProviderMigrator - tests passing * PM-5268 - Update DeviceTrustCryptoService to convert over to state providers + update all service instantiations / dependencies to ensure state provider is passed in or injected. * PM-5268 - Register new migration * PM-5268 - Temporarily remove device trust crypto service from migrator to ease merge conflicts as there are 6 more migrators before I can apply mine in main. * PM-5268 - Update migration numbers of DeviceTrustCryptoServiceStateProviderMigrator based on latest migrations from main. * PM-5268 - (1) Export new KeyDefinitions from DeviceTrustCryptoService for use in test suite (2) Update DeviceTrustCryptoService test file to use state provider. * PM-5268 - Fix DeviceTrustCryptoServiceStateProviderMigrator tests to use proper versions * PM-5268 - Actually fix all instances of DeviceTrustCryptoServiceStateProviderMigrator test failures * PM-5268 - Clean up state service, account, and login strategy of all migrated references * PM-5268 - Account - finish cleaning up device key * PM-5268 - StateService - clean up last reference to device key * PM-5268 - Remove even more device key refs. *facepalm* * PM-5268 - Finish resolving merge conflicts by incrementing migration version from 22 to 23 * PM-5268 - bump migration versions * PM-5268 - DeviceTrustCryptoService - Implement secure storage functionality for getDeviceKey and setDeviceKey (to achieve feature parity with the ElectronStateService implementation prior to the state provider migration). Tests to follow shortly. * PM-5268 - DeviceTrustCryptoService tests - getDeviceKey now tested with all new secure storage scenarios. SetDeviceKey tests to follow. * PM-5268 - DeviceTrustCryptoService tests - test all setDeviceKey scenarios with state provider & secure storage * PM-5268 - Update DeviceTrustCryptoService deps to actually use secure storage svc on platforms that support it. * PM-5268 - Bump migration version due to merge conflicts. * PM-5268 - Bump migration version * PM-5268 - tweak jsdocs to be single line per PR feedback * PM-5268 - DeviceTrustCryptoSvc - improve debuggability. * PM-5268 - Remove state service as a dependency on the device trust crypto service (woo!) * PM-5268 - Update migration test json to correctly reflect reality. * PM-5268 - DeviceTrustCryptoSvc - getDeviceKey - add throw error for active user id missing. * PM-5268 - Fix tests * PM-5268 - WIP start on adding user id to every method on device trust crypto service. * PM-5268 - Update lock comp dependencies across clients * PM-5268 - Update login via auth request deps across clients to add acct service. * PM-5268 - UserKeyRotationSvc - add acct service to get active acct id for call to rotateDevicesTrust and then update tests. * PM-5268 - WIP on trying to fix device trust crypto svc tests. * PM-5268 - More WIP device trust crypto svc tests passing * PM-5268 - Device Trust crypto service - get all tests passing * PM-5268 - DeviceTrustCryptoService.getDeviceKey - fix secure storage b64 to symmetric crypto key conversion * PM-5268 - Add more tests and update test names * PM-5268 - rename state to indicate it was disk local * PM-5268 - DeviceTrustCryptoService - save symmetric key in JSON format * PM-5268 - Fix lock comp tests by adding acct service dep * PM-5268 - Update set device key tests to pass * PM-5268 - Bump migration versions again * PM-5268 - Fix user key rotation svc tests * PM-5268 - Update web jest config to allow use of common spec in user-key-rotation-svc tests * PM-5268 - Bump migration version * PM-5268 - Per PR feedback, save off user id * PM-5268 - bump migration version * PM-5268 - Per PR feedback, remove unnecessary await. * PM-5268 - Bump migration verson --- .../device-trust-crypto-service.factory.ts | 16 +- apps/browser/src/auth/popup/lock.component.ts | 3 + .../popup/login-via-auth-request.component.ts | 3 + .../browser/src/background/main.background.ts | 3 +- apps/cli/src/bw.ts | 3 +- apps/desktop/src/auth/lock.component.spec.ts | 11 + apps/desktop/src/auth/lock.component.ts | 3 + .../login/login-via-auth-request.component.ts | 3 + .../services/electron-state.service.ts | 35 -- apps/web/jest.config.js | 10 +- .../user-key-rotation.service.spec.ts | 7 + .../key-rotation/user-key-rotation.service.ts | 9 +- apps/web/src/app/auth/lock.component.ts | 3 + ...base-login-decryption-options.component.ts | 16 +- .../src/auth/components/lock.component.ts | 5 +- .../login-via-auth-request.component.ts | 7 +- .../src/services/jslib-services.module.ts | 3 +- .../auth-request-login.strategy.ts | 5 +- .../login-strategies/login.strategy.spec.ts | 25 +- .../common/login-strategies/login.strategy.ts | 12 - .../login-strategies/sso-login.strategy.ts | 9 +- ...device-trust-crypto.service.abstraction.ts | 22 +- ...ice-trust-crypto.service.implementation.ts | 138 ++++++-- .../device-trust-crypto.service.spec.ts | 335 ++++++++++++------ .../platform/abstractions/state.service.ts | 6 +- .../models/domain/account-keys.spec.ts | 38 -- .../src/platform/models/domain/account.ts | 3 - .../src/platform/services/state.service.ts | 61 +--- .../src/platform/state/state-definitions.ts | 3 + libs/common/src/state-migrations/migrate.ts | 7 +- ...rust-crypto-svc-to-state-providers.spec.ts | 171 +++++++++ ...ice-trust-crypto-svc-to-state-providers.ts | 95 +++++ 32 files changed, 737 insertions(+), 333 deletions(-) create mode 100644 libs/common/src/state-migrations/migrations/53-migrate-device-trust-crypto-svc-to-state-providers.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/53-migrate-device-trust-crypto-svc-to-state-providers.ts diff --git a/apps/browser/src/auth/background/service-factories/device-trust-crypto-service.factory.ts b/apps/browser/src/auth/background/service-factories/device-trust-crypto-service.factory.ts index 5916f38441f1..cac6f9bbe8a7 100644 --- a/apps/browser/src/auth/background/service-factories/device-trust-crypto-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/device-trust-crypto-service.factory.ts @@ -39,9 +39,13 @@ import { platformUtilsServiceFactory, } from "../../../platform/background/service-factories/platform-utils-service.factory"; import { - StateServiceInitOptions, - stateServiceFactory, -} from "../../../platform/background/service-factories/state-service.factory"; + StateProviderInitOptions, + stateProviderFactory, +} from "../../../platform/background/service-factories/state-provider.factory"; +import { + SecureStorageServiceInitOptions, + secureStorageServiceFactory, +} from "../../../platform/background/service-factories/storage-service.factory"; import { UserDecryptionOptionsServiceInitOptions, @@ -55,11 +59,12 @@ export type DeviceTrustCryptoServiceInitOptions = DeviceTrustCryptoServiceFactor CryptoFunctionServiceInitOptions & CryptoServiceInitOptions & EncryptServiceInitOptions & - StateServiceInitOptions & AppIdServiceInitOptions & DevicesApiServiceInitOptions & I18nServiceInitOptions & PlatformUtilsServiceInitOptions & + StateProviderInitOptions & + SecureStorageServiceInitOptions & UserDecryptionOptionsServiceInitOptions; export function deviceTrustCryptoServiceFactory( @@ -76,11 +81,12 @@ export function deviceTrustCryptoServiceFactory( await cryptoFunctionServiceFactory(cache, opts), await cryptoServiceFactory(cache, opts), await encryptServiceFactory(cache, opts), - await stateServiceFactory(cache, opts), await appIdServiceFactory(cache, opts), await devicesApiServiceFactory(cache, opts), await i18nServiceFactory(cache, opts), await platformUtilsServiceFactory(cache, opts), + await stateProviderFactory(cache, opts), + await secureStorageServiceFactory(cache, opts), await userDecryptionOptionsServiceFactory(cache, opts), ), ); diff --git a/apps/browser/src/auth/popup/lock.component.ts b/apps/browser/src/auth/popup/lock.component.ts index f2c56a23aefd..f232eca45a73 100644 --- a/apps/browser/src/auth/popup/lock.component.ts +++ b/apps/browser/src/auth/popup/lock.component.ts @@ -9,6 +9,7 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; @@ -62,6 +63,7 @@ export class LockComponent extends BaseLockComponent { pinCryptoService: PinCryptoServiceAbstraction, private routerService: BrowserRouterService, biometricStateService: BiometricStateService, + accountService: AccountService, ) { super( router, @@ -84,6 +86,7 @@ export class LockComponent extends BaseLockComponent { userVerificationService, pinCryptoService, biometricStateService, + accountService, ); this.successRoute = "/tabs/current"; this.isInitialLockScreen = (window as any).previousPopupUrl == null; diff --git a/apps/browser/src/auth/popup/login-via-auth-request.component.ts b/apps/browser/src/auth/popup/login-via-auth-request.component.ts index 8d438d5b7862..52f311ce7b76 100644 --- a/apps/browser/src/auth/popup/login-via-auth-request.component.ts +++ b/apps/browser/src/auth/popup/login-via-auth-request.component.ts @@ -9,6 +9,7 @@ import { LoginEmailServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AnonymousHubService } from "@bitwarden/common/auth/abstractions/anonymous-hub.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; @@ -49,6 +50,7 @@ export class LoginViaAuthRequestComponent extends BaseLoginWithDeviceComponent { deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, authRequestService: AuthRequestServiceAbstraction, loginStrategyService: LoginStrategyServiceAbstraction, + accountService: AccountService, private location: Location, ) { super( @@ -70,6 +72,7 @@ export class LoginViaAuthRequestComponent extends BaseLoginWithDeviceComponent { deviceTrustCryptoService, authRequestService, loginStrategyService, + accountService, ); super.onSuccessfulLogin = async () => { await syncService.fullSync(true); diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 49b4b9624901..25befdcf800b 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -556,11 +556,12 @@ export default class MainBackground { this.cryptoFunctionService, this.cryptoService, this.encryptService, - this.stateService, this.appIdService, this.devicesApiService, this.i18nService, this.platformUtilsService, + this.stateProvider, + this.secureStorageService, this.userDecryptionOptionsService, ); diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index bba381b84a30..804b05e8e381 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -455,11 +455,12 @@ export class Main { this.cryptoFunctionService, this.cryptoService, this.encryptService, - this.stateService, this.appIdService, this.devicesApiService, this.i18nService, this.platformUtilsService, + this.stateProvider, + this.secureStorageService, this.userDecryptionOptionsService, ); diff --git a/apps/desktop/src/auth/lock.component.spec.ts b/apps/desktop/src/auth/lock.component.spec.ts index 6ecf93deb848..0339889bf75e 100644 --- a/apps/desktop/src/auth/lock.component.spec.ts +++ b/apps/desktop/src/auth/lock.component.spec.ts @@ -12,6 +12,7 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; @@ -23,7 +24,10 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { UserId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; import { LockComponent } from "./lock.component"; @@ -49,6 +53,9 @@ describe("LockComponent", () => { let platformUtilsServiceMock: MockProxy; let activatedRouteMock: MockProxy; + const mockUserId = Utils.newGuid() as UserId; + const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); + beforeEach(async () => { stateServiceMock = mock(); stateServiceMock.activeAccount$ = of(null); @@ -147,6 +154,10 @@ describe("LockComponent", () => { provide: BiometricStateService, useValue: biometricStateService, }, + { + provide: AccountService, + useValue: accountService, + }, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); diff --git a/apps/desktop/src/auth/lock.component.ts b/apps/desktop/src/auth/lock.component.ts index 7403f7481d2b..8b1448c06fcc 100644 --- a/apps/desktop/src/auth/lock.component.ts +++ b/apps/desktop/src/auth/lock.component.ts @@ -9,6 +9,7 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { DeviceType } from "@bitwarden/common/enums"; @@ -59,6 +60,7 @@ export class LockComponent extends BaseLockComponent { userVerificationService: UserVerificationService, pinCryptoService: PinCryptoServiceAbstraction, biometricStateService: BiometricStateService, + accountService: AccountService, ) { super( router, @@ -81,6 +83,7 @@ export class LockComponent extends BaseLockComponent { userVerificationService, pinCryptoService, biometricStateService, + accountService, ); } diff --git a/apps/desktop/src/auth/login/login-via-auth-request.component.ts b/apps/desktop/src/auth/login/login-via-auth-request.component.ts index 28163d09d090..0a339030ba2a 100644 --- a/apps/desktop/src/auth/login/login-via-auth-request.component.ts +++ b/apps/desktop/src/auth/login/login-via-auth-request.component.ts @@ -10,6 +10,7 @@ import { LoginEmailServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AnonymousHubService } from "@bitwarden/common/auth/abstractions/anonymous-hub.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; @@ -57,6 +58,7 @@ export class LoginViaAuthRequestComponent extends BaseLoginWithDeviceComponent { deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, authRequestService: AuthRequestServiceAbstraction, loginStrategyService: LoginStrategyServiceAbstraction, + accountService: AccountService, private location: Location, ) { super( @@ -78,6 +80,7 @@ export class LoginViaAuthRequestComponent extends BaseLoginWithDeviceComponent { deviceTrustCryptoService, authRequestService, loginStrategyService, + accountService, ); super.onSuccessfulLogin = () => { diff --git a/apps/desktop/src/platform/services/electron-state.service.ts b/apps/desktop/src/platform/services/electron-state.service.ts index f4399221d2db..33c97f48afe1 100644 --- a/apps/desktop/src/platform/services/electron-state.service.ts +++ b/apps/desktop/src/platform/services/electron-state.service.ts @@ -1,47 +1,12 @@ -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; -import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { StateService as BaseStateService } from "@bitwarden/common/platform/services/state.service"; -import { DeviceKey } from "@bitwarden/common/types/key"; import { Account } from "../../models/account"; export class ElectronStateService extends BaseStateService { - private partialKeys = { - deviceKey: "_deviceKey", - }; - async addAccount(account: Account) { // Apply desktop overides to default account values account = new Account(account); await super.addAccount(account); } - - override async getDeviceKey(options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultSecureStorageOptions()); - if (options?.userId == null) { - return; - } - - const b64DeviceKey = await this.secureStorageService.get( - `${options.userId}${this.partialKeys.deviceKey}`, - options, - ); - - if (b64DeviceKey == null) { - return null; - } - - return new SymmetricCryptoKey(Utils.fromB64ToArray(b64DeviceKey)) as DeviceKey; - } - - override async setDeviceKey(value: DeviceKey, options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultSecureStorageOptions()); - if (options?.userId == null) { - return; - } - - await this.saveSecureStorageKey(this.partialKeys.deviceKey, value.keyB64, options); - } } diff --git a/apps/web/jest.config.js b/apps/web/jest.config.js index cde02cd9959b..f121823adeea 100644 --- a/apps/web/jest.config.js +++ b/apps/web/jest.config.js @@ -9,7 +9,11 @@ module.exports = { ...sharedConfig, preset: "jest-preset-angular", setupFilesAfterEnv: ["/test.setup.ts"], - moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, { - prefix: "/", - }), + moduleNameMapper: pathsToModuleNameMapper( + // lets us use @bitwarden/common/spec in web tests + { "@bitwarden/common/spec": ["../../libs/common/spec"], ...(compilerOptions?.paths ?? {}) }, + { + prefix: "/", + }, + ), }; diff --git a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts index 7eabbbb5c19e..09c7bf9ace45 100644 --- a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts +++ b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.spec.ts @@ -6,10 +6,13 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { EncryptionType } from "@bitwarden/common/platform/enums"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { Send } from "@bitwarden/common/tools/send/models/domain/send"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -41,6 +44,9 @@ describe("KeyRotationService", () => { let mockStateService: MockProxy; let mockConfigService: MockProxy; + const mockUserId = Utils.newGuid() as UserId; + const mockAccountService: FakeAccountService = mockAccountServiceWith(mockUserId); + beforeAll(() => { mockApiService = mock(); mockCipherService = mock(); @@ -65,6 +71,7 @@ describe("KeyRotationService", () => { mockCryptoService, mockEncryptService, mockStateService, + mockAccountService, mockConfigService, ); }); diff --git a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts index b53c71cb2e27..03bc604b4d8e 100644 --- a/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts +++ b/apps/web/src/app/auth/key-rotation/user-key-rotation.service.ts @@ -1,6 +1,7 @@ import { Injectable } from "@angular/core"; import { firstValueFrom } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -34,6 +35,7 @@ export class UserKeyRotationService { private cryptoService: CryptoService, private encryptService: EncryptService, private stateService: StateService, + private accountService: AccountService, private configService: ConfigService, ) {} @@ -90,7 +92,12 @@ export class UserKeyRotationService { await this.rotateUserKeyAndEncryptedDataLegacy(request); } - await this.deviceTrustCryptoService.rotateDevicesTrust(newUserKey, masterPasswordHash); + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + await this.deviceTrustCryptoService.rotateDevicesTrust( + activeAccount.id, + newUserKey, + masterPasswordHash, + ); } private async encryptPrivateKey(newUserKey: UserKey): Promise { diff --git a/apps/web/src/app/auth/lock.component.ts b/apps/web/src/app/auth/lock.component.ts index c4f8d276bb01..a1d472439690 100644 --- a/apps/web/src/app/auth/lock.component.ts +++ b/apps/web/src/app/auth/lock.component.ts @@ -8,6 +8,7 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -47,6 +48,7 @@ export class LockComponent extends BaseLockComponent { userVerificationService: UserVerificationService, pinCryptoService: PinCryptoServiceAbstraction, biometricStateService: BiometricStateService, + accountService: AccountService, ) { super( router, @@ -69,6 +71,7 @@ export class LockComponent extends BaseLockComponent { userVerificationService, pinCryptoService, biometricStateService, + accountService, ); } diff --git a/libs/angular/src/auth/components/base-login-decryption-options.component.ts b/libs/angular/src/auth/components/base-login-decryption-options.component.ts index 6bb545c4b537..8345bb993966 100644 --- a/libs/angular/src/auth/components/base-login-decryption-options.component.ts +++ b/libs/angular/src/auth/components/base-login-decryption-options.component.ts @@ -22,6 +22,7 @@ import { import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.service.abstraction"; @@ -34,6 +35,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { UserId } from "@bitwarden/common/types/guid"; enum State { NewUser, @@ -65,6 +67,8 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy { protected data?: Data; protected loading = true; + activeAccountId: UserId; + // Remember device means for the user to trust the device rememberDeviceForm = this.formBuilder.group({ rememberDevice: [true], @@ -94,10 +98,12 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy { protected userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, protected passwordResetEnrollmentService: PasswordResetEnrollmentServiceAbstraction, protected ssoLoginService: SsoLoginServiceAbstraction, + protected accountService: AccountService, ) {} async ngOnInit() { this.loading = true; + this.activeAccountId = (await firstValueFrom(this.accountService.activeAccount$))?.id; this.setupRememberDeviceValueChanges(); @@ -150,7 +156,9 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy { } private async setRememberDeviceDefaultValue() { - const rememberDeviceFromState = await this.deviceTrustCryptoService.getShouldTrustDevice(); + const rememberDeviceFromState = await this.deviceTrustCryptoService.getShouldTrustDevice( + this.activeAccountId, + ); const rememberDevice = rememberDeviceFromState ?? true; @@ -161,7 +169,9 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy { this.rememberDevice.valueChanges .pipe( switchMap((value) => - defer(() => this.deviceTrustCryptoService.setShouldTrustDevice(value)), + defer(() => + this.deviceTrustCryptoService.setShouldTrustDevice(this.activeAccountId, value), + ), ), takeUntil(this.destroy$), ) @@ -278,7 +288,7 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy { await this.passwordResetEnrollmentService.enroll(this.data.organizationId); if (this.rememberDeviceForm.value.rememberDevice) { - await this.deviceTrustCryptoService.trustDevice(); + await this.deviceTrustCryptoService.trustDevice(this.activeAccountId); } } catch (error) { this.validationService.showError(error); diff --git a/libs/angular/src/auth/components/lock.component.ts b/libs/angular/src/auth/components/lock.component.ts index c21ba1a75a16..aa3b801ded5a 100644 --- a/libs/angular/src/auth/components/lock.component.ts +++ b/libs/angular/src/auth/components/lock.component.ts @@ -10,6 +10,7 @@ import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeou import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; @@ -75,6 +76,7 @@ export class LockComponent implements OnInit, OnDestroy { protected userVerificationService: UserVerificationService, protected pinCryptoService: PinCryptoServiceAbstraction, protected biometricStateService: BiometricStateService, + protected accountService: AccountService, ) {} async ngOnInit() { @@ -269,7 +271,8 @@ export class LockComponent implements OnInit, OnDestroy { // Now that we have a decrypted user key in memory, we can check if we // need to establish trust on the current device - await this.deviceTrustCryptoService.trustDeviceIfRequired(); + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + await this.deviceTrustCryptoService.trustDeviceIfRequired(activeAccount.id); await this.doContinue(evaluatePasswordAfterUnlock); } diff --git a/libs/angular/src/auth/components/login-via-auth-request.component.ts b/libs/angular/src/auth/components/login-via-auth-request.component.ts index 66b7c1918cdd..6ba94d300111 100644 --- a/libs/angular/src/auth/components/login-via-auth-request.component.ts +++ b/libs/angular/src/auth/components/login-via-auth-request.component.ts @@ -1,6 +1,6 @@ import { Directive, OnDestroy, OnInit } from "@angular/core"; import { IsActiveMatchOptions, Router } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; +import { Subject, firstValueFrom, takeUntil } from "rxjs"; import { AuthRequestLoginCredentials, @@ -9,6 +9,7 @@ import { LoginEmailServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AnonymousHubService } from "@bitwarden/common/auth/abstractions/anonymous-hub.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; @@ -87,6 +88,7 @@ export class LoginViaAuthRequestComponent private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, private authRequestService: AuthRequestServiceAbstraction, private loginStrategyService: LoginStrategyServiceAbstraction, + private accountService: AccountService, ) { super(environmentService, i18nService, platformUtilsService); @@ -388,7 +390,8 @@ export class LoginViaAuthRequestComponent // Now that we have a decrypted user key in memory, we can check if we // need to establish trust on the current device - await this.deviceTrustCryptoService.trustDeviceIfRequired(); + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + await this.deviceTrustCryptoService.trustDeviceIfRequired(activeAccount.id); // TODO: don't forget to use auto enrollment service everywhere we trust device diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index c9a39eed0ae8..9a8a2bc6a25e 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -912,11 +912,12 @@ const safeProviders: SafeProvider[] = [ CryptoFunctionServiceAbstraction, CryptoServiceAbstraction, EncryptService, - StateServiceAbstraction, AppIdServiceAbstraction, DevicesApiServiceAbstraction, I18nServiceAbstraction, PlatformUtilsServiceAbstraction, + StateProvider, + SECURE_STORAGE, UserDecryptionOptionsServiceAbstraction, ], }), diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts index c42f43e7643f..31a0cebbfee9 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.ts @@ -16,6 +16,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { UserId } from "@bitwarden/common/types/guid"; import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction"; import { AuthRequestLoginCredentials } from "../models/domain/login-credentials"; @@ -128,8 +129,10 @@ export class AuthRequestLoginStrategy extends LoginStrategy { await this.cryptoService.setUserKey(authRequestCredentials.decryptedUserKey); } else { await this.trySetUserKeyWithMasterKey(); + + const userId = (await this.stateService.getUserId()) as UserId; // Establish trust if required after setting user key - await this.deviceTrustCryptoService.trustDeviceIfRequired(); + await this.deviceTrustCryptoService.trustDeviceIfRequired(userId); } } diff --git a/libs/auth/src/common/login-strategies/login.strategy.spec.ts b/libs/auth/src/common/login-strategies/login.strategy.spec.ts index 42541808c8ca..0ac22047c5bd 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.spec.ts @@ -36,7 +36,7 @@ import { PasswordStrengthService, } from "@bitwarden/common/tools/password-strength"; import { CsprngArray } from "@bitwarden/common/types/csprng"; -import { UserKey, MasterKey, DeviceKey } from "@bitwarden/common/types/key"; +import { UserKey, MasterKey } from "@bitwarden/common/types/key"; import { LoginStrategyServiceAbstraction } from "../abstractions"; import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction"; @@ -215,29 +215,6 @@ describe("LoginStrategy", () => { expect(messagingService.send).toHaveBeenCalledWith("loggedIn"); }); - it("persists a device key for trusted device encryption when it exists on login", async () => { - // Arrange - const idTokenResponse = identityTokenResponseFactory(); - apiService.postIdentityToken.mockResolvedValue(idTokenResponse); - - const deviceKey = new SymmetricCryptoKey( - new Uint8Array(userKeyBytesLength).buffer as CsprngArray, - ) as DeviceKey; - - stateService.getDeviceKey.mockResolvedValue(deviceKey); - - const accountKeys = new AccountKeys(); - accountKeys.deviceKey = deviceKey; - - // Act - await passwordLoginStrategy.logIn(credentials); - - // Assert - expect(stateService.addAccount).toHaveBeenCalledWith( - expect.objectContaining({ keys: accountKeys }), - ); - }); - it("builds AuthResult", async () => { const tokenResponse = identityTokenResponseFactory(); tokenResponse.forcePasswordReset = true; diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index 8e927c2cc485..4fe99b276cf7 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -26,7 +26,6 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { - AccountKeys, Account, AccountProfile, AccountTokens, @@ -160,18 +159,8 @@ export abstract class LoginStrategy { protected async saveAccountInformation(tokenResponse: IdentityTokenResponse): Promise { const accountInformation = await this.tokenService.decodeAccessToken(tokenResponse.accessToken); - // Must persist existing device key if it exists for trusted device decryption to work - // However, we must provide a user id so that the device key can be retrieved - // as the state service won't have an active account at this point in time - // even though the data exists in local storage. const userId = accountInformation.sub; - const deviceKey = await this.stateService.getDeviceKey({ userId }); - const accountKeys = new AccountKeys(); - if (deviceKey) { - accountKeys.deviceKey = deviceKey; - } - // If you don't persist existing admin auth requests on login, they will get deleted. const adminAuthRequest = await this.stateService.getAdminAuthRequest({ userId }); @@ -204,7 +193,6 @@ export abstract class LoginStrategy { tokens: { ...new AccountTokens(), }, - keys: accountKeys, adminAuthRequest: adminAuthRequest?.toJSON(), }), ); diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.ts index 04f158d30a98..7745104bd15b 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.ts @@ -20,6 +20,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { UserId } from "@bitwarden/common/types/guid"; import { InternalUserDecryptionOptionsServiceAbstraction, @@ -284,7 +285,8 @@ export class SsoLoginStrategy extends LoginStrategy { if (await this.cryptoService.hasUserKey()) { // Now that we have a decrypted user key in memory, we can check if we // need to establish trust on the current device - await this.deviceTrustCryptoService.trustDeviceIfRequired(); + const userId = (await this.stateService.getUserId()) as UserId; + await this.deviceTrustCryptoService.trustDeviceIfRequired(userId); // if we successfully decrypted the user key, we can delete the admin auth request out of state // TODO: eventually we post and clean up DB as well once consumed on client @@ -298,7 +300,9 @@ export class SsoLoginStrategy extends LoginStrategy { private async trySetUserKeyWithDeviceKey(tokenResponse: IdentityTokenResponse): Promise { const trustedDeviceOption = tokenResponse.userDecryptionOptions?.trustedDeviceOption; - const deviceKey = await this.deviceTrustCryptoService.getDeviceKey(); + const userId = (await this.stateService.getUserId()) as UserId; + + const deviceKey = await this.deviceTrustCryptoService.getDeviceKey(userId); const encDevicePrivateKey = trustedDeviceOption?.encryptedPrivateKey; const encUserKey = trustedDeviceOption?.encryptedUserKey; @@ -307,6 +311,7 @@ export class SsoLoginStrategy extends LoginStrategy { } const userKey = await this.deviceTrustCryptoService.decryptUserKeyWithDeviceKey( + userId, encDevicePrivateKey, encUserKey, deviceKey, diff --git a/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts b/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts index 415355cfc773..53fe21403530 100644 --- a/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/device-trust-crypto.service.abstraction.ts @@ -1,6 +1,7 @@ import { Observable } from "rxjs"; import { EncString } from "../../platform/models/domain/enc-string"; +import { UserId } from "../../types/guid"; import { DeviceKey, UserKey } from "../../types/key"; import { DeviceResponse } from "../abstractions/devices/responses/device.response"; @@ -10,17 +11,24 @@ export abstract class DeviceTrustCryptoServiceAbstraction { * @description Retrieves the users choice to trust the device which can only happen after decryption * Note: this value should only be used once and then reset */ - getShouldTrustDevice: () => Promise; - setShouldTrustDevice: (value: boolean) => Promise; + getShouldTrustDevice: (userId: UserId) => Promise; + setShouldTrustDevice: (userId: UserId, value: boolean) => Promise; - trustDeviceIfRequired: () => Promise; + trustDeviceIfRequired: (userId: UserId) => Promise; - trustDevice: () => Promise; - getDeviceKey: () => Promise; + trustDevice: (userId: UserId) => Promise; + + /** Retrieves the device key if it exists from state or secure storage if supported for the active user. */ + getDeviceKey: (userId: UserId) => Promise; decryptUserKeyWithDeviceKey: ( + userId: UserId, encryptedDevicePrivateKey: EncString, encryptedUserKey: EncString, - deviceKey?: DeviceKey, + deviceKey: DeviceKey, ) => Promise; - rotateDevicesTrust: (newUserKey: UserKey, masterPasswordHash: string) => Promise; + rotateDevicesTrust: ( + userId: UserId, + newUserKey: UserKey, + masterPasswordHash: string, + ) => Promise; } diff --git a/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts b/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts index 71f83f07c3b2..e65c5cd499a8 100644 --- a/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts +++ b/libs/common/src/auth/services/device-trust-crypto.service.implementation.ts @@ -9,9 +9,13 @@ import { EncryptService } from "../../platform/abstractions/encrypt.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; import { KeyGenerationService } from "../../platform/abstractions/key-generation.service"; import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; -import { StateService } from "../../platform/abstractions/state.service"; +import { AbstractStorageService } from "../../platform/abstractions/storage.service"; +import { StorageLocation } from "../../platform/enums"; import { EncString } from "../../platform/models/domain/enc-string"; +import { StorageOptions } from "../../platform/models/domain/storage-options"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; +import { DEVICE_TRUST_DISK_LOCAL, KeyDefinition, StateProvider } from "../../platform/state"; +import { UserId } from "../../types/guid"; import { UserKey, DeviceKey } from "../../types/key"; import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction"; import { DeviceResponse } from "../abstractions/devices/responses/device.response"; @@ -22,7 +26,25 @@ import { UpdateDevicesTrustRequest, } from "../models/request/update-devices-trust.request"; +/** Uses disk storage so that the device key can persist after log out and tab removal. */ +export const DEVICE_KEY = new KeyDefinition(DEVICE_TRUST_DISK_LOCAL, "deviceKey", { + deserializer: (deviceKey) => SymmetricCryptoKey.fromJSON(deviceKey) as DeviceKey, +}); + +/** Uses disk storage so that the shouldTrustDevice bool can persist across login. */ +export const SHOULD_TRUST_DEVICE = new KeyDefinition( + DEVICE_TRUST_DISK_LOCAL, + "shouldTrustDevice", + { + deserializer: (shouldTrustDevice) => shouldTrustDevice, + }, +); + export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstraction { + private readonly platformSupportsSecureStorage = + this.platformUtilsService.supportsSecureStorage(); + private readonly deviceKeySecureStorageKey: string = "_deviceKey"; + supportsDeviceTrust$: Observable; constructor( @@ -30,11 +52,12 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac private cryptoFunctionService: CryptoFunctionService, private cryptoService: CryptoService, private encryptService: EncryptService, - private stateService: StateService, private appIdService: AppIdService, private devicesApiService: DevicesApiServiceAbstraction, private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, + private stateProvider: StateProvider, + private secureStorageService: AbstractStorageService, private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, ) { this.supportsDeviceTrust$ = this.userDecryptionOptionsService.userDecryptionOptions$.pipe( @@ -46,24 +69,44 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac * @description Retrieves the users choice to trust the device which can only happen after decryption * Note: this value should only be used once and then reset */ - async getShouldTrustDevice(): Promise { - return await this.stateService.getShouldTrustDevice(); + async getShouldTrustDevice(userId: UserId): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot get should trust device."); + } + + const shouldTrustDevice = await firstValueFrom( + this.stateProvider.getUserState$(SHOULD_TRUST_DEVICE, userId), + ); + + return shouldTrustDevice; } - async setShouldTrustDevice(value: boolean): Promise { - await this.stateService.setShouldTrustDevice(value); + async setShouldTrustDevice(userId: UserId, value: boolean): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot set should trust device."); + } + + await this.stateProvider.setUserState(SHOULD_TRUST_DEVICE, value, userId); } - async trustDeviceIfRequired(): Promise { - const shouldTrustDevice = await this.getShouldTrustDevice(); + async trustDeviceIfRequired(userId: UserId): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot trust device if required."); + } + + const shouldTrustDevice = await this.getShouldTrustDevice(userId); if (shouldTrustDevice) { - await this.trustDevice(); + await this.trustDevice(userId); // reset the trust choice - await this.setShouldTrustDevice(false); + await this.setShouldTrustDevice(userId, false); } } - async trustDevice(): Promise { + async trustDevice(userId: UserId): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot trust device."); + } + // Attempt to get user key const userKey: UserKey = await this.cryptoService.getUserKey(); @@ -104,15 +147,23 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac ); // store device key in local/secure storage if enc keys posted to server successfully - await this.setDeviceKey(deviceKey); + await this.setDeviceKey(userId, deviceKey); this.platformUtilsService.showToast("success", null, this.i18nService.t("deviceTrusted")); return deviceResponse; } - async rotateDevicesTrust(newUserKey: UserKey, masterPasswordHash: string): Promise { - const currentDeviceKey = await this.getDeviceKey(); + async rotateDevicesTrust( + userId: UserId, + newUserKey: UserKey, + masterPasswordHash: string, + ): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot rotate device's trust."); + } + + const currentDeviceKey = await this.getDeviceKey(userId); if (currentDeviceKey == null) { // If the current device doesn't have a device key available to it, then we can't // rotate any trust at all, so early return. @@ -165,26 +216,59 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac await this.devicesApiService.updateTrust(trustRequest, deviceIdentifier); } - async getDeviceKey(): Promise { - return await this.stateService.getDeviceKey(); + async getDeviceKey(userId: UserId): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot get device key."); + } + + if (this.platformSupportsSecureStorage) { + const deviceKeyB64 = await this.secureStorageService.get< + ReturnType + >(`${userId}${this.deviceKeySecureStorageKey}`, this.getSecureStorageOptions(userId)); + + const deviceKey = SymmetricCryptoKey.fromJSON(deviceKeyB64) as DeviceKey; + + return deviceKey; + } + + const deviceKey = await firstValueFrom(this.stateProvider.getUserState$(DEVICE_KEY, userId)); + + return deviceKey; } - private async setDeviceKey(deviceKey: DeviceKey | null): Promise { - await this.stateService.setDeviceKey(deviceKey); + private async setDeviceKey(userId: UserId, deviceKey: DeviceKey | null): Promise { + if (!userId) { + throw new Error("UserId is required. Cannot set device key."); + } + + if (this.platformSupportsSecureStorage) { + await this.secureStorageService.save( + `${userId}${this.deviceKeySecureStorageKey}`, + deviceKey, + this.getSecureStorageOptions(userId), + ); + return; + } + + await this.stateProvider.setUserState(DEVICE_KEY, deviceKey?.toJSON(), userId); } private async makeDeviceKey(): Promise { // Create 512-bit device key - return (await this.keyGenerationService.createKey(512)) as DeviceKey; + const deviceKey = (await this.keyGenerationService.createKey(512)) as DeviceKey; + + return deviceKey; } async decryptUserKeyWithDeviceKey( + userId: UserId, encryptedDevicePrivateKey: EncString, encryptedUserKey: EncString, - deviceKey?: DeviceKey, + deviceKey: DeviceKey, ): Promise { - // If device key provided use it, otherwise try to retrieve from storage - deviceKey ||= await this.getDeviceKey(); + if (!userId) { + throw new Error("UserId is required. Cannot decrypt user key with device key."); + } if (!deviceKey) { // User doesn't have a device key anymore so device is untrusted @@ -207,9 +291,17 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac return new SymmetricCryptoKey(userKey) as UserKey; } catch (e) { // If either decryption effort fails, we want to remove the device key - await this.setDeviceKey(null); + await this.setDeviceKey(userId, null); return null; } } + + private getSecureStorageOptions(userId: UserId): StorageOptions { + return { + storageLocation: StorageLocation.Disk, + useSecureStorage: true, + userId: userId, + }; + } } diff --git a/libs/common/src/auth/services/device-trust-crypto.service.spec.ts b/libs/common/src/auth/services/device-trust-crypto.service.spec.ts index 1d33223dddbb..af147b3481df 100644 --- a/libs/common/src/auth/services/device-trust-crypto.service.spec.ts +++ b/libs/common/src/auth/services/device-trust-crypto.service.spec.ts @@ -4,6 +4,9 @@ import { BehaviorSubject, of } from "rxjs"; import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import { UserDecryptionOptions } from "../../../../auth/src/common/models/domain/user-decryption-options"; +import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service"; +import { FakeActiveUserState } from "../../../spec/fake-state"; +import { FakeStateProvider } from "../../../spec/fake-state-provider"; import { DeviceType } from "../../enums"; import { AppIdService } from "../../platform/abstractions/app-id.service"; import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service"; @@ -12,18 +15,26 @@ import { EncryptService } from "../../platform/abstractions/encrypt.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; import { KeyGenerationService } from "../../platform/abstractions/key-generation.service"; import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; -import { StateService } from "../../platform/abstractions/state.service"; +import { AbstractStorageService } from "../../platform/abstractions/storage.service"; +import { StorageLocation } from "../../platform/enums"; import { EncryptionType } from "../../platform/enums/encryption-type.enum"; +import { Utils } from "../../platform/misc/utils"; import { EncString } from "../../platform/models/domain/enc-string"; +import { StorageOptions } from "../../platform/models/domain/storage-options"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "../../types/csprng"; +import { UserId } from "../../types/guid"; import { DeviceKey, UserKey } from "../../types/key"; import { DeviceResponse } from "../abstractions/devices/responses/device.response"; import { DevicesApiServiceAbstraction } from "../abstractions/devices-api.service.abstraction"; import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request"; import { ProtectedDeviceResponse } from "../models/response/protected-device.response"; -import { DeviceTrustCryptoService } from "./device-trust-crypto.service.implementation"; +import { + SHOULD_TRUST_DEVICE, + DEVICE_KEY, + DeviceTrustCryptoService, +} from "./device-trust-crypto.service.implementation"; describe("deviceTrustCryptoService", () => { let deviceTrustCryptoService: DeviceTrustCryptoService; @@ -32,33 +43,34 @@ describe("deviceTrustCryptoService", () => { const cryptoFunctionService = mock(); const cryptoService = mock(); const encryptService = mock(); - const stateService = mock(); const appIdService = mock(); const devicesApiService = mock(); const i18nService = mock(); const platformUtilsService = mock(); - const userDecryptionOptionsService = mock(); + const secureStorageService = mock(); + const userDecryptionOptionsService = mock(); const decryptionOptions = new BehaviorSubject(null); - beforeEach(() => { - jest.clearAllMocks(); + let stateProvider: FakeStateProvider; - decryptionOptions.next({} as any); - userDecryptionOptionsService.userDecryptionOptions$ = decryptionOptions; + const mockUserId = Utils.newGuid() as UserId; + let accountService: FakeAccountService; - deviceTrustCryptoService = new DeviceTrustCryptoService( - keyGenerationService, - cryptoFunctionService, - cryptoService, - encryptService, - stateService, - appIdService, - devicesApiService, - i18nService, - platformUtilsService, - userDecryptionOptionsService, - ); + const deviceKeyPartialSecureStorageKey = "_deviceKey"; + const deviceKeySecureStorageKey = `${mockUserId}${deviceKeyPartialSecureStorageKey}`; + + const secureStorageOptions: StorageOptions = { + storageLocation: StorageLocation.Disk, + useSecureStorage: true, + userId: mockUserId, + }; + + beforeEach(() => { + jest.clearAllMocks(); + const supportsSecureStorage = false; // default to false; tests will override as needed + // By default all the tests will have a mocked active user in state provider. + deviceTrustCryptoService = createDeviceTrustCryptoService(mockUserId, supportsSecureStorage); }); it("instantiates", () => { @@ -67,27 +79,26 @@ describe("deviceTrustCryptoService", () => { describe("User Trust Device Choice For Decryption", () => { describe("getShouldTrustDevice", () => { - it("gets the user trust device choice for decryption from the state service", async () => { - const stateSvcGetShouldTrustDeviceSpy = jest.spyOn(stateService, "getShouldTrustDevice"); + it("gets the user trust device choice for decryption", async () => { + const newValue = true; + + await stateProvider.setUserState(SHOULD_TRUST_DEVICE, newValue, mockUserId); - const expectedValue = true; - stateSvcGetShouldTrustDeviceSpy.mockResolvedValue(expectedValue); - const result = await deviceTrustCryptoService.getShouldTrustDevice(); + const result = await deviceTrustCryptoService.getShouldTrustDevice(mockUserId); - expect(stateSvcGetShouldTrustDeviceSpy).toHaveBeenCalledTimes(1); - expect(result).toEqual(expectedValue); + expect(result).toEqual(newValue); }); }); describe("setShouldTrustDevice", () => { - it("sets the user trust device choice for decryption in the state service", async () => { - const stateSvcSetShouldTrustDeviceSpy = jest.spyOn(stateService, "setShouldTrustDevice"); + it("sets the user trust device choice for decryption ", async () => { + await stateProvider.setUserState(SHOULD_TRUST_DEVICE, false, mockUserId); const newValue = true; - await deviceTrustCryptoService.setShouldTrustDevice(newValue); + await deviceTrustCryptoService.setShouldTrustDevice(mockUserId, newValue); - expect(stateSvcSetShouldTrustDeviceSpy).toHaveBeenCalledTimes(1); - expect(stateSvcSetShouldTrustDeviceSpy).toHaveBeenCalledWith(newValue); + const result = await deviceTrustCryptoService.getShouldTrustDevice(mockUserId); + expect(result).toEqual(newValue); }); }); }); @@ -98,11 +109,11 @@ describe("deviceTrustCryptoService", () => { jest.spyOn(deviceTrustCryptoService, "trustDevice").mockResolvedValue({} as DeviceResponse); jest.spyOn(deviceTrustCryptoService, "setShouldTrustDevice").mockResolvedValue(); - await deviceTrustCryptoService.trustDeviceIfRequired(); + await deviceTrustCryptoService.trustDeviceIfRequired(mockUserId); expect(deviceTrustCryptoService.getShouldTrustDevice).toHaveBeenCalledTimes(1); expect(deviceTrustCryptoService.trustDevice).toHaveBeenCalledTimes(1); - expect(deviceTrustCryptoService.setShouldTrustDevice).toHaveBeenCalledWith(false); + expect(deviceTrustCryptoService.setShouldTrustDevice).toHaveBeenCalledWith(mockUserId, false); }); it("should not trust device nor reset when getShouldTrustDevice returns false", async () => { @@ -112,7 +123,7 @@ describe("deviceTrustCryptoService", () => { const trustDeviceSpy = jest.spyOn(deviceTrustCryptoService, "trustDevice"); const setShouldTrustDeviceSpy = jest.spyOn(deviceTrustCryptoService, "setShouldTrustDevice"); - await deviceTrustCryptoService.trustDeviceIfRequired(); + await deviceTrustCryptoService.trustDeviceIfRequired(mockUserId); expect(getShouldTrustDeviceSpy).toHaveBeenCalledTimes(1); expect(trustDeviceSpy).not.toHaveBeenCalled(); @@ -126,53 +137,140 @@ describe("deviceTrustCryptoService", () => { describe("getDeviceKey", () => { let existingDeviceKey: DeviceKey; - let stateSvcGetDeviceKeySpy: jest.SpyInstance; + let existingDeviceKeyB64: { keyB64: string }; beforeEach(() => { existingDeviceKey = new SymmetricCryptoKey( new Uint8Array(deviceKeyBytesLength) as CsprngArray, ) as DeviceKey; - stateSvcGetDeviceKeySpy = jest.spyOn(stateService, "getDeviceKey"); + existingDeviceKeyB64 = existingDeviceKey.toJSON(); }); - it("returns null when there is not an existing device key", async () => { - stateSvcGetDeviceKeySpy.mockResolvedValue(null); + describe("Secure Storage not supported", () => { + it("returns null when there is not an existing device key", async () => { + await stateProvider.setUserState(DEVICE_KEY, null, mockUserId); + + const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId); - const deviceKey = await deviceTrustCryptoService.getDeviceKey(); + expect(deviceKey).toBeNull(); + expect(secureStorageService.get).not.toHaveBeenCalled(); + }); + + it("returns the device key when there is an existing device key", async () => { + await stateProvider.setUserState(DEVICE_KEY, existingDeviceKey, mockUserId); - expect(stateSvcGetDeviceKeySpy).toHaveBeenCalledTimes(1); + const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId); - expect(deviceKey).toBeNull(); + expect(deviceKey).not.toBeNull(); + expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey); + expect(deviceKey).toEqual(existingDeviceKey); + expect(secureStorageService.get).not.toHaveBeenCalled(); + }); }); - it("returns the device key when there is an existing device key", async () => { - stateSvcGetDeviceKeySpy.mockResolvedValue(existingDeviceKey); + describe("Secure Storage supported", () => { + beforeEach(() => { + const supportsSecureStorage = true; + deviceTrustCryptoService = createDeviceTrustCryptoService( + mockUserId, + supportsSecureStorage, + ); + }); - const deviceKey = await deviceTrustCryptoService.getDeviceKey(); + it("returns null when there is not an existing device key for the passed in user id", async () => { + secureStorageService.get.mockResolvedValue(null); - expect(stateSvcGetDeviceKeySpy).toHaveBeenCalledTimes(1); + // Act + const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId); - expect(deviceKey).not.toBeNull(); - expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey); - expect(deviceKey).toEqual(existingDeviceKey); + // Assert + expect(deviceKey).toBeNull(); + }); + + it("returns the device key when there is an existing device key for the passed in user id", async () => { + // Arrange + secureStorageService.get.mockResolvedValue(existingDeviceKeyB64); + + // Act + const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId); + + // Assert + expect(deviceKey).not.toBeNull(); + expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey); + expect(deviceKey).toEqual(existingDeviceKey); + }); + }); + + it("throws an error when no user id is passed in", async () => { + await expect(deviceTrustCryptoService.getDeviceKey(null)).rejects.toThrow( + "UserId is required. Cannot get device key.", + ); }); }); describe("setDeviceKey", () => { - it("sets the device key in the state service", async () => { - const stateSvcSetDeviceKeySpy = jest.spyOn(stateService, "setDeviceKey"); + describe("Secure Storage not supported", () => { + it("successfully sets the device key in state provider", async () => { + await stateProvider.setUserState(DEVICE_KEY, null, mockUserId); + + const newDeviceKey = new SymmetricCryptoKey( + new Uint8Array(deviceKeyBytesLength) as CsprngArray, + ) as DeviceKey; + + // TypeScript will allow calling private methods if the object is of type 'any' + // This is a hacky workaround, but it allows for cleaner tests + await (deviceTrustCryptoService as any).setDeviceKey(mockUserId, newDeviceKey); + + expect(stateProvider.mock.setUserState).toHaveBeenLastCalledWith( + DEVICE_KEY, + newDeviceKey.toJSON(), + mockUserId, + ); + }); + }); + describe("Secure Storage supported", () => { + beforeEach(() => { + const supportsSecureStorage = true; + deviceTrustCryptoService = createDeviceTrustCryptoService( + mockUserId, + supportsSecureStorage, + ); + }); + + it("successfully sets the device key in secure storage", async () => { + // Arrange + await stateProvider.setUserState(DEVICE_KEY, null, mockUserId); + + secureStorageService.get.mockResolvedValue(null); + + const newDeviceKey = new SymmetricCryptoKey( + new Uint8Array(deviceKeyBytesLength) as CsprngArray, + ) as DeviceKey; + + // Act + // TypeScript will allow calling private methods if the object is of type 'any' + // This is a hacky workaround, but it allows for cleaner tests + await (deviceTrustCryptoService as any).setDeviceKey(mockUserId, newDeviceKey); + + // Assert + expect(stateProvider.mock.setUserState).not.toHaveBeenCalledTimes(2); + expect(secureStorageService.save).toHaveBeenCalledWith( + deviceKeySecureStorageKey, + newDeviceKey, + secureStorageOptions, + ); + }); + }); - const deviceKey = new SymmetricCryptoKey( + it("throws an error when a null user id is passed in", async () => { + const newDeviceKey = new SymmetricCryptoKey( new Uint8Array(deviceKeyBytesLength) as CsprngArray, ) as DeviceKey; - // TypeScript will allow calling private methods if the object is of type 'any' - // This is a hacky workaround, but it allows for cleaner tests - await (deviceTrustCryptoService as any).setDeviceKey(deviceKey); - - expect(stateSvcSetDeviceKeySpy).toHaveBeenCalledTimes(1); - expect(stateSvcSetDeviceKeySpy).toHaveBeenCalledWith(deviceKey); + await expect( + (deviceTrustCryptoService as any).setDeviceKey(null, newDeviceKey), + ).rejects.toThrow("UserId is required. Cannot set device key."); }); }); @@ -300,7 +398,7 @@ describe("deviceTrustCryptoService", () => { }); it("calls the required methods with the correct arguments and returns a DeviceResponse", async () => { - const response = await deviceTrustCryptoService.trustDevice(); + const response = await deviceTrustCryptoService.trustDevice(mockUserId); expect(makeDeviceKeySpy).toHaveBeenCalledTimes(1); expect(rsaGenerateKeyPairSpy).toHaveBeenCalledTimes(1); @@ -331,7 +429,7 @@ describe("deviceTrustCryptoService", () => { // setup the spy to return null cryptoSvcGetUserKeySpy.mockResolvedValue(null); // check if the expected error is thrown - await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow( + await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow( "User symmetric key not found", ); @@ -341,7 +439,7 @@ describe("deviceTrustCryptoService", () => { // setup the spy to return undefined cryptoSvcGetUserKeySpy.mockResolvedValue(undefined); // check if the expected error is thrown - await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow( + await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow( "User symmetric key not found", ); }); @@ -381,7 +479,9 @@ describe("deviceTrustCryptoService", () => { it(`throws an error if ${method} fails`, async () => { const methodSpy = spy(); methodSpy.mockRejectedValue(new Error(errorText)); - await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow(errorText); + await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow( + errorText, + ); }); test.each([null, undefined])( @@ -389,11 +489,17 @@ describe("deviceTrustCryptoService", () => { async (invalidValue) => { const methodSpy = spy(); methodSpy.mockResolvedValue(invalidValue); - await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow(); + await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow(); }, ); }, ); + + it("throws an error when a null user id is passed in", async () => { + await expect(deviceTrustCryptoService.trustDevice(null)).rejects.toThrow( + "UserId is required. Cannot trust device.", + ); + }); }); describe("decryptUserKeyWithDeviceKey", () => { @@ -422,19 +528,26 @@ describe("deviceTrustCryptoService", () => { jest.clearAllMocks(); }); - it("returns null when device key isn't provided and isn't in state", async () => { - const getDeviceKeySpy = jest - .spyOn(deviceTrustCryptoService, "getDeviceKey") - .mockResolvedValue(null); + it("throws an error when a null user id is passed in", async () => { + await expect( + deviceTrustCryptoService.decryptUserKeyWithDeviceKey( + null, + mockEncryptedDevicePrivateKey, + mockEncryptedUserKey, + mockDeviceKey, + ), + ).rejects.toThrow("UserId is required. Cannot decrypt user key with device key."); + }); + it("returns null when device key isn't provided", async () => { const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey( + mockUserId, mockEncryptedDevicePrivateKey, mockEncryptedUserKey, + mockDeviceKey, ); expect(result).toBeNull(); - - expect(getDeviceKeySpy).toHaveBeenCalledTimes(1); }); it("successfully returns the user key when provided keys (including device key) can decrypt it", async () => { @@ -446,6 +559,7 @@ describe("deviceTrustCryptoService", () => { .mockResolvedValue(new Uint8Array(userKeyBytesLength)); const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey( + mockUserId, mockEncryptedDevicePrivateKey, mockEncryptedUserKey, mockDeviceKey, @@ -456,31 +570,6 @@ describe("deviceTrustCryptoService", () => { expect(rsaDecryptSpy).toHaveBeenCalledTimes(1); }); - it("successfully returns the user key when a device key is not provided (retrieves device key from state)", async () => { - const getDeviceKeySpy = jest - .spyOn(deviceTrustCryptoService, "getDeviceKey") - .mockResolvedValue(mockDeviceKey); - - const decryptToBytesSpy = jest - .spyOn(encryptService, "decryptToBytes") - .mockResolvedValue(new Uint8Array(userKeyBytesLength)); - const rsaDecryptSpy = jest - .spyOn(cryptoService, "rsaDecrypt") - .mockResolvedValue(new Uint8Array(userKeyBytesLength)); - - // Call without providing a device key - const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey( - mockEncryptedDevicePrivateKey, - mockEncryptedUserKey, - ); - - expect(getDeviceKeySpy).toHaveBeenCalledTimes(1); - - expect(result).toEqual(mockUserKey); - expect(decryptToBytesSpy).toHaveBeenCalledTimes(1); - expect(rsaDecryptSpy).toHaveBeenCalledTimes(1); - }); - it("returns null and removes device key when the decryption fails", async () => { const decryptToBytesSpy = jest .spyOn(encryptService, "decryptToBytes") @@ -488,6 +577,7 @@ describe("deviceTrustCryptoService", () => { const setDeviceKeySpy = jest.spyOn(deviceTrustCryptoService as any, "setDeviceKey"); const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey( + mockUserId, mockEncryptedDevicePrivateKey, mockEncryptedUserKey, mockDeviceKey, @@ -496,7 +586,7 @@ describe("deviceTrustCryptoService", () => { expect(result).toBeNull(); expect(decryptToBytesSpy).toHaveBeenCalledTimes(1); expect(setDeviceKeySpy).toHaveBeenCalledTimes(1); - expect(setDeviceKeySpy).toHaveBeenCalledWith(null); + expect(setDeviceKeySpy).toHaveBeenCalledWith(mockUserId, null); }); }); @@ -514,19 +604,28 @@ describe("deviceTrustCryptoService", () => { cryptoService.activeUserKey$ = of(fakeNewUserKey); }); + it("throws an error when a null user id is passed in", async () => { + await expect( + deviceTrustCryptoService.rotateDevicesTrust(null, fakeNewUserKey, ""), + ).rejects.toThrow("UserId is required. Cannot rotate device's trust."); + }); + it("does an early exit when the current device is not a trusted device", async () => { - stateService.getDeviceKey.mockResolvedValue(null); + const deviceKeyState: FakeActiveUserState = + stateProvider.activeUser.getFake(DEVICE_KEY); + deviceKeyState.nextState(null); - await deviceTrustCryptoService.rotateDevicesTrust(fakeNewUserKey, ""); + await deviceTrustCryptoService.rotateDevicesTrust(mockUserId, fakeNewUserKey, ""); expect(devicesApiService.updateTrust).not.toHaveBeenCalled(); }); describe("is on a trusted device", () => { - beforeEach(() => { - stateService.getDeviceKey.mockResolvedValue( - new SymmetricCryptoKey(new Uint8Array(deviceKeyBytesLength)) as DeviceKey, - ); + beforeEach(async () => { + const mockDeviceKey = new SymmetricCryptoKey( + new Uint8Array(deviceKeyBytesLength), + ) as DeviceKey; + await stateProvider.setUserState(DEVICE_KEY, mockDeviceKey, mockUserId); }); it("rotates current device keys and calls api service when the current device is trusted", async () => { @@ -592,7 +691,11 @@ describe("deviceTrustCryptoService", () => { ); }); - await deviceTrustCryptoService.rotateDevicesTrust(fakeNewUserKey, "my_password_hash"); + await deviceTrustCryptoService.rotateDevicesTrust( + mockUserId, + fakeNewUserKey, + "my_password_hash", + ); expect(devicesApiService.updateTrust).toHaveBeenCalledWith( matches((updateTrustModel: UpdateDevicesTrustRequest) => { @@ -608,4 +711,32 @@ describe("deviceTrustCryptoService", () => { }); }); }); + + // Helpers + function createDeviceTrustCryptoService( + mockUserId: UserId | null, + supportsSecureStorage: boolean, + ) { + accountService = mockAccountServiceWith(mockUserId); + stateProvider = new FakeStateProvider(accountService); + + platformUtilsService.supportsSecureStorage.mockReturnValue(supportsSecureStorage); + + decryptionOptions.next({} as any); + userDecryptionOptionsService.userDecryptionOptions$ = decryptionOptions; + + return new DeviceTrustCryptoService( + keyGenerationService, + cryptoFunctionService, + cryptoService, + encryptService, + appIdService, + devicesApiService, + i18nService, + platformUtilsService, + stateProvider, + secureStorageService, + userDecryptionOptionsService, + ); + } }); diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index ab8b5489511f..9bc6d698a759 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -10,7 +10,7 @@ import { UsernameGeneratorOptions } from "../../tools/generator/username"; import { SendData } from "../../tools/send/models/data/send.data"; import { SendView } from "../../tools/send/models/view/send.view"; import { UserId } from "../../types/guid"; -import { DeviceKey, MasterKey } from "../../types/key"; +import { MasterKey } from "../../types/key"; import { CipherData } from "../../vault/models/data/cipher.data"; import { LocalData } from "../../vault/models/data/local.data"; import { CipherView } from "../../vault/models/view/cipher.view"; @@ -161,15 +161,11 @@ export abstract class StateService { setDecryptedSends: (value: SendView[], options?: StorageOptions) => Promise; getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise; setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise; - getDeviceKey: (options?: StorageOptions) => Promise; - setDeviceKey: (value: DeviceKey | null, options?: StorageOptions) => Promise; getAdminAuthRequest: (options?: StorageOptions) => Promise; setAdminAuthRequest: ( adminAuthRequest: AdminAuthRequestStorable, options?: StorageOptions, ) => Promise; - getShouldTrustDevice: (options?: StorageOptions) => Promise; - setShouldTrustDevice: (value: boolean, options?: StorageOptions) => Promise; getEmail: (options?: StorageOptions) => Promise; setEmail: (value: string, options?: StorageOptions) => Promise; getEmailVerified: (options?: StorageOptions) => Promise; diff --git a/libs/common/src/platform/models/domain/account-keys.spec.ts b/libs/common/src/platform/models/domain/account-keys.spec.ts index 7041acc5bae6..4a96da1b4898 100644 --- a/libs/common/src/platform/models/domain/account-keys.spec.ts +++ b/libs/common/src/platform/models/domain/account-keys.spec.ts @@ -1,6 +1,4 @@ import { makeStaticByteArray } from "../../../../spec"; -import { CsprngArray } from "../../../types/csprng"; -import { DeviceKey } from "../../../types/key"; import { Utils } from "../../misc/utils"; import { AccountKeys, EncryptionPair } from "./account"; @@ -24,23 +22,6 @@ describe("AccountKeys", () => { const json = JSON.stringify(keys); expect(json).toContain('"publicKey":"hello"'); }); - - // As the accountKeys.toJSON doesn't really serialize the device key - // this method just checks the persistence of the deviceKey - it("should persist deviceKey", () => { - // Arrange - const accountKeys = new AccountKeys(); - const deviceKeyBytesLength = 64; - accountKeys.deviceKey = new SymmetricCryptoKey( - new Uint8Array(deviceKeyBytesLength).buffer as CsprngArray, - ) as DeviceKey; - - // Act - const serializedKeys = accountKeys.toJSON(); - - // Assert - expect(serializedKeys.deviceKey).toEqual(accountKeys.deviceKey); - }); }); describe("fromJSON", () => { @@ -64,24 +45,5 @@ describe("AccountKeys", () => { } as any); expect(spy).toHaveBeenCalled(); }); - - it("should deserialize deviceKey", () => { - // Arrange - const expectedKeyB64 = - "ZJNnhx9BbJeb2EAq1hlMjqt6GFsg9G/GzoFf6SbPKsaiMhKGDcbHcwcyEg56Lh8lfilpZz4SRM6UA7oFCg+lSg=="; - - const symmetricCryptoKeyFromJsonSpy = jest.spyOn(SymmetricCryptoKey, "fromJSON"); - - // Act - const accountKeys = AccountKeys.fromJSON({ - deviceKey: { - keyB64: expectedKeyB64, - }, - } as any); - - // Assert - expect(symmetricCryptoKeyFromJsonSpy).toHaveBeenCalled(); - expect(accountKeys.deviceKey.keyB64).toEqual(expectedKeyB64); - }); }); }); diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index 61bb3eeac5f6..798a60600a64 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -95,7 +95,6 @@ export class AccountData { export class AccountKeys { masterKey?: MasterKey; masterKeyEncryptedUserKey?: string; - deviceKey?: ReturnType; publicKey?: Uint8Array; /** @deprecated July 2023, left for migration purposes*/ @@ -125,7 +124,6 @@ export class AccountKeys { } return Object.assign(new AccountKeys(), obj, { masterKey: SymmetricCryptoKey.fromJSON(obj?.masterKey), - deviceKey: obj?.deviceKey, cryptoMasterKey: SymmetricCryptoKey.fromJSON(obj?.cryptoMasterKey), cryptoSymmetricKey: EncryptionPair.fromJSON( obj?.cryptoSymmetricKey, @@ -185,7 +183,6 @@ export class AccountSettings { vaultTimeout?: number; vaultTimeoutAction?: string = "lock"; approveLoginRequests?: boolean; - trustDeviceChoiceForDecryption?: boolean; /** @deprecated July 2023, left for migration purposes*/ pinProtected?: EncryptionPair = new EncryptionPair(); diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index 0e524b6c4b90..57a2085ccf5f 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -14,7 +14,7 @@ import { UsernameGeneratorOptions } from "../../tools/generator/username"; import { SendData } from "../../tools/send/models/data/send.data"; import { SendView } from "../../tools/send/models/view/send.view"; import { UserId } from "../../types/guid"; -import { DeviceKey, MasterKey } from "../../types/key"; +import { MasterKey } from "../../types/key"; import { CipherData } from "../../vault/models/data/cipher.data"; import { LocalData } from "../../vault/models/data/local.data"; import { CipherView } from "../../vault/models/view/cipher.view"; @@ -650,39 +650,6 @@ export class StateService< : await this.secureStorageService.save(DDG_SHARED_KEY, value, options); } - async getDeviceKey(options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); - - if (options?.userId == null) { - return null; - } - - const account = await this.getAccount(options); - - const existingDeviceKey = account?.keys?.deviceKey; - - // Must manually instantiate the SymmetricCryptoKey class from the JSON object - if (existingDeviceKey != null) { - return SymmetricCryptoKey.fromJSON(existingDeviceKey) as DeviceKey; - } else { - return null; - } - } - - async setDeviceKey(value: DeviceKey | null, options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); - - if (options?.userId == null) { - return; - } - - const account = await this.getAccount(options); - - account.keys.deviceKey = value?.toJSON() ?? null; - - await this.saveAccount(account, options); - } - async getAdminAuthRequest(options?: StorageOptions): Promise { options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); @@ -714,31 +681,6 @@ export class StateService< await this.saveAccount(account, options); } - async getShouldTrustDevice(options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); - - if (options?.userId == null) { - return null; - } - - const account = await this.getAccount(options); - - return account?.settings?.trustDeviceChoiceForDecryption ?? null; - } - - async setShouldTrustDevice(value: boolean, options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); - if (options?.userId == null) { - return; - } - - const account = await this.getAccount(options); - - account.settings.trustDeviceChoiceForDecryption = value; - - await this.saveAccount(account, options); - } - async getEmail(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) @@ -1633,7 +1575,6 @@ export class StateService< protected resetAccount(account: TAccount) { const persistentAccountInformation = { settings: account.settings, - keys: { deviceKey: account.keys.deviceKey }, adminAuthRequest: account.adminAuthRequest, }; return Object.assign(this.createAccount(), persistentAccountInformation); diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 814bf0280f0e..466c3a2c111d 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -48,6 +48,9 @@ export const TOKEN_DISK_LOCAL = new StateDefinition("tokenDiskLocal", "disk", { web: "disk-local", }); export const TOKEN_MEMORY = new StateDefinition("token", "memory"); +export const DEVICE_TRUST_DISK_LOCAL = new StateDefinition("deviceTrust", "disk", { + web: "disk-local", +}); export const USER_DECRYPTION_OPTIONS_DISK = new StateDefinition("userDecryptionOptions", "disk"); // Autofill diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index 0758d49f5992..4e1a0529fc92 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -49,6 +49,7 @@ import { AddKeyTypeToOrgKeysMigrator } from "./migrations/5-add-key-type-to-org- import { KeyConnectorMigrator } from "./migrations/50-move-key-connector-to-state-provider"; import { RememberedEmailMigrator } from "./migrations/51-move-remembered-email-to-state-providers"; import { DeleteInstalledVersion } from "./migrations/52-delete-installed-version"; +import { DeviceTrustCryptoServiceStateProviderMigrator } from "./migrations/53-migrate-device-trust-crypto-svc-to-state-providers"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; import { MoveStateVersionMigrator } from "./migrations/8-move-state-version"; @@ -56,8 +57,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 3; -export const CURRENT_VERSION = 52; - +export const CURRENT_VERSION = 53; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -111,7 +111,8 @@ export function createMigrationBuilder() { .with(AccountServerConfigMigrator, 48, 49) .with(KeyConnectorMigrator, 49, 50) .with(RememberedEmailMigrator, 50, 51) - .with(DeleteInstalledVersion, 51, CURRENT_VERSION); + .with(DeleteInstalledVersion, 51, 52) + .with(DeviceTrustCryptoServiceStateProviderMigrator, 52, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/53-migrate-device-trust-crypto-svc-to-state-providers.spec.ts b/libs/common/src/state-migrations/migrations/53-migrate-device-trust-crypto-svc-to-state-providers.spec.ts new file mode 100644 index 000000000000..79366a471673 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/53-migrate-device-trust-crypto-svc-to-state-providers.spec.ts @@ -0,0 +1,171 @@ +import { MockProxy, any } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { + DEVICE_KEY, + DeviceTrustCryptoServiceStateProviderMigrator, + SHOULD_TRUST_DEVICE, +} from "./53-migrate-device-trust-crypto-svc-to-state-providers"; + +// Represents data in state service pre-migration +function preMigrationJson() { + return { + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user1", "user2", "user3"], + user1: { + keys: { + deviceKey: { + keyB64: "user1_deviceKey", + }, + otherStuff: "overStuff2", + }, + settings: { + trustDeviceChoiceForDecryption: true, + otherStuff: "overStuff3", + }, + otherStuff: "otherStuff4", + }, + user2: { + keys: { + // no device key + otherStuff: "otherStuff5", + }, + settings: { + // no trust device choice + otherStuff: "overStuff6", + }, + otherStuff: "otherStuff7", + }, + }; +} + +function rollbackJSON() { + return { + // use pattern user_{userId}_{stateDefinitionName}_{keyDefinitionKey} for each user + // User1 migrated data + user_user1_deviceTrust_deviceKey: { + keyB64: "user1_deviceKey", + }, + user_user1_deviceTrust_shouldTrustDevice: true, + + // User2 does not have migrated data + + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user1", "user2", "user3"], + user1: { + keys: { + otherStuff: "overStuff2", + }, + settings: { + otherStuff: "overStuff3", + }, + otherStuff: "otherStuff4", + }, + user2: { + keys: { + otherStuff: "otherStuff5", + }, + settings: { + otherStuff: "overStuff6", + }, + otherStuff: "otherStuff6", + }, + }; +} + +describe("DeviceTrustCryptoServiceStateProviderMigrator", () => { + let helper: MockProxy; + let sut: DeviceTrustCryptoServiceStateProviderMigrator; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(preMigrationJson(), 52); + sut = new DeviceTrustCryptoServiceStateProviderMigrator(52, 53); + }); + + // it should remove deviceKey and trustDeviceChoiceForDecryption from all accounts + it("should remove deviceKey and trustDeviceChoiceForDecryption from all accounts that have it", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("user1", { + keys: { + otherStuff: "overStuff2", + }, + settings: { + otherStuff: "overStuff3", + }, + otherStuff: "otherStuff4", + }); + + expect(helper.set).toHaveBeenCalledTimes(1); + expect(helper.set).not.toHaveBeenCalledWith("user2", any()); + expect(helper.set).not.toHaveBeenCalledWith("user3", any()); + }); + + it("should migrate deviceKey and trustDeviceChoiceForDecryption to state providers for accounts that have the data", async () => { + await sut.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("user1", DEVICE_KEY, { + keyB64: "user1_deviceKey", + }); + expect(helper.setToUser).toHaveBeenCalledWith("user1", SHOULD_TRUST_DEVICE, true); + + expect(helper.setToUser).not.toHaveBeenCalledWith("user2", DEVICE_KEY, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith("user2", SHOULD_TRUST_DEVICE, any()); + + expect(helper.setToUser).not.toHaveBeenCalledWith("user3", DEVICE_KEY, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith("user3", SHOULD_TRUST_DEVICE, any()); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 53); + sut = new DeviceTrustCryptoServiceStateProviderMigrator(52, 53); + }); + + it("should null out newly migrated entries in state provider framework", async () => { + await sut.rollback(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("user1", DEVICE_KEY, null); + expect(helper.setToUser).toHaveBeenCalledWith("user1", SHOULD_TRUST_DEVICE, null); + + expect(helper.setToUser).toHaveBeenCalledWith("user2", DEVICE_KEY, null); + expect(helper.setToUser).toHaveBeenCalledWith("user2", SHOULD_TRUST_DEVICE, null); + + expect(helper.setToUser).toHaveBeenCalledWith("user3", DEVICE_KEY, null); + expect(helper.setToUser).toHaveBeenCalledWith("user3", SHOULD_TRUST_DEVICE, null); + }); + + it("should add back deviceKey and trustDeviceChoiceForDecryption to all accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledWith("user1", { + keys: { + deviceKey: { + keyB64: "user1_deviceKey", + }, + otherStuff: "overStuff2", + }, + settings: { + trustDeviceChoiceForDecryption: true, + otherStuff: "overStuff3", + }, + otherStuff: "otherStuff4", + }); + }); + + it("should not add data back if data wasn't migrated or acct doesn't exist", async () => { + await sut.rollback(helper); + + // no data to add back for user2 (acct exists but no migrated data) and user3 (no acct) + expect(helper.set).not.toHaveBeenCalledWith("user2", any()); + expect(helper.set).not.toHaveBeenCalledWith("user3", any()); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/53-migrate-device-trust-crypto-svc-to-state-providers.ts b/libs/common/src/state-migrations/migrations/53-migrate-device-trust-crypto-svc-to-state-providers.ts new file mode 100644 index 000000000000..e19c7b3fa5a7 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/53-migrate-device-trust-crypto-svc-to-state-providers.ts @@ -0,0 +1,95 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +// Types to represent data as it is stored in JSON +type DeviceKeyJsonType = { + keyB64: string; +}; + +type ExpectedAccountType = { + keys?: { + deviceKey?: DeviceKeyJsonType; + }; + settings?: { + trustDeviceChoiceForDecryption?: boolean; + }; +}; + +export const DEVICE_KEY: KeyDefinitionLike = { + key: "deviceKey", // matches KeyDefinition.key in DeviceTrustCryptoService + stateDefinition: { + name: "deviceTrust", // matches StateDefinition.name in StateDefinitions + }, +}; + +export const SHOULD_TRUST_DEVICE: KeyDefinitionLike = { + key: "shouldTrustDevice", + stateDefinition: { + name: "deviceTrust", + }, +}; + +export class DeviceTrustCryptoServiceStateProviderMigrator extends Migrator<52, 53> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + async function migrateAccount(userId: string, account: ExpectedAccountType): Promise { + let updatedAccount = false; + + // Migrate deviceKey + const existingDeviceKey = account?.keys?.deviceKey; + + if (existingDeviceKey != null) { + // Only migrate data that exists + await helper.setToUser(userId, DEVICE_KEY, existingDeviceKey); + delete account.keys.deviceKey; + updatedAccount = true; + } + + // Migrate shouldTrustDevice + const existingShouldTrustDevice = account?.settings?.trustDeviceChoiceForDecryption; + + if (existingShouldTrustDevice != null) { + await helper.setToUser(userId, SHOULD_TRUST_DEVICE, existingShouldTrustDevice); + delete account.settings.trustDeviceChoiceForDecryption; + updatedAccount = true; + } + + if (updatedAccount) { + // Save the migrated account + await helper.set(userId, account); + } + } + + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + } + + async rollback(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise { + // Rollback deviceKey + const migratedDeviceKey: DeviceKeyJsonType = await helper.getFromUser(userId, DEVICE_KEY); + + if (account?.keys && migratedDeviceKey != null) { + account.keys.deviceKey = migratedDeviceKey; + await helper.set(userId, account); + } + + await helper.setToUser(userId, DEVICE_KEY, null); + + // Rollback shouldTrustDevice + const migratedShouldTrustDevice = await helper.getFromUser( + userId, + SHOULD_TRUST_DEVICE, + ); + + if (account?.settings && migratedShouldTrustDevice != null) { + account.settings.trustDeviceChoiceForDecryption = migratedShouldTrustDevice; + await helper.set(userId, account); + } + + await helper.setToUser(userId, SHOULD_TRUST_DEVICE, null); + } + + await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]); + } +} From 2316222e180d218516bc56e09183880f96273c9a Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Mon, 1 Apr 2024 15:24:04 -0500 Subject: [PATCH 09/18] Update SAST preset to query set (#8569) --- .checkmarx/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.checkmarx/config.yml b/.checkmarx/config.yml index 18b9be6a7e4e..e45e83fcac92 100644 --- a/.checkmarx/config.yml +++ b/.checkmarx/config.yml @@ -7,5 +7,6 @@ checkmarx: scan: configs: sast: + presetName: "BW ASA Premium" # Exclude spec files, and test specific files filter: "!*.spec.ts,!**/spec/**,!apps/desktop/native-messaging-test-runner/**" From bdb1aa0a045a201dd9b6c1b3891be3952019f6c0 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Mon, 1 Apr 2024 14:11:07 -0700 Subject: [PATCH 10/18] fix dep (#8570) --- libs/angular/src/services/jslib-services.module.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 9a8a2bc6a25e..114c60c19367 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -350,7 +350,7 @@ const safeProviders: SafeProvider[] = [ CryptoServiceAbstraction, ApiServiceAbstraction, StateServiceAbstraction, - TokenService, + TokenServiceAbstraction, ], }), safeProvider({ From 11c40036e28ff9f1483536a2a515b99e9d00d42e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 2 Apr 2024 11:22:57 +0200 Subject: [PATCH 11/18] [deps] Platform: Update Rust crate arboard to v3.3.2 (#8186) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/desktop/desktop_native/Cargo.lock | 69 ++++++-------------------- apps/desktop/desktop_native/Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 54 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index e99d8b4fc4bb..446bce87a072 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -45,9 +45,9 @@ checksum = "5ad32ce52e4161730f7098c077cd2ed6229b5804ccf99e5366be1ab72a98b4e1" [[package]] name = "arboard" -version = "3.3.0" +version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aafb29b107435aa276664c1db8954ac27a6e105cdad3c88287a199eb0e313c08" +checksum = "a2041f1943049c7978768d84e6d0fd95de98b76d6c4727b09e78ec253d29fa58" dependencies = [ "clipboard-win", "log", @@ -56,7 +56,6 @@ dependencies = [ "objc_id", "parking_lot", "thiserror", - "winapi", "wl-clipboard-rs", "x11rb", ] @@ -176,13 +175,11 @@ dependencies = [ [[package]] name = "clipboard-win" -version = "4.5.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7191c27c2357d9b7ef96baac1773290d4ca63b24205b82a3fd8a0637afcf0362" +checksum = "d517d4b86184dbb111d3556a10f1c8a04da7428d2987bf1081602bf11c3aa9ee" dependencies = [ "error-code", - "str-buf", - "winapi", ] [[package]] @@ -348,7 +345,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" dependencies = [ - "libloading 0.7.4", + "libloading", ] [[package]] @@ -375,13 +372,9 @@ dependencies = [ [[package]] name = "error-code" -version = "2.3.1" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64f18991e7bf11e7ffee451b5318b5c1a73c52d0d0ada6e5a3017c8c1ced6a21" -dependencies = [ - "libc", - "str-buf", -] +checksum = "a0474425d51df81997e2f90a21591180b38eccf27292d755f3e30750225c175b" [[package]] name = "fastrand" @@ -476,12 +469,12 @@ dependencies = [ [[package]] name = "gethostname" -version = "0.3.0" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb65d4ba3173c56a500b555b532f72c42e8d1fe64962b518897f8959fae2c177" +checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" dependencies = [ "libc", - "winapi", + "windows-targets 0.48.5", ] [[package]] @@ -659,16 +652,6 @@ version = "0.2.152" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7" -[[package]] -name = "libloading" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" -dependencies = [ - "cfg-if", - "winapi", -] - [[package]] name = "libloading" version = "0.8.3" @@ -830,7 +813,7 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2503fa6af34dc83fb74888df8b22afe933b58d37daf7d80424b1c60c68196b8b" dependencies = [ - "libloading 0.8.3", + "libloading", ] [[package]] @@ -1211,12 +1194,6 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" -[[package]] -name = "str-buf" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e08d8363704e6c71fc928674353e6b7c23dcea9d82d7012c8faf2a3a025f8d0" - [[package]] name = "syn" version = "1.0.109" @@ -1516,15 +1493,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "winapi-wsapoll" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c17110f57155602a80dca10be03852116403c9ff3cd25b079d666f2aa3df6e" -dependencies = [ - "winapi", -] - [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -1714,22 +1682,17 @@ dependencies = [ [[package]] name = "x11rb" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1641b26d4dec61337c35a1b1aaf9e3cba8f46f0b43636c609ab0291a648040a" +checksum = "f8f25ead8c7e4cba123243a6367da5d3990e0d3affa708ea19dce96356bd9f1a" dependencies = [ "gethostname", - "nix", - "winapi", - "winapi-wsapoll", + "rustix", "x11rb-protocol", ] [[package]] name = "x11rb-protocol" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82d6c3f9a0fb6701fab8f6cea9b0c0bd5d6876f1f89f7fada07e558077c344bc" -dependencies = [ - "nix", -] +checksum = "e63e71c4b8bd9ffec2c963173a4dc4cbde9ee96961d4fcb4429db9929b606c34" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index cf1082d81c25..a1625020e54b 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -15,7 +15,7 @@ manual_test = [] [dependencies] aes = "=0.8.4" anyhow = "=1.0.80" -arboard = { version = "=3.3.0", default-features = false, features = ["wayland-data-control"] } +arboard = { version = "=3.3.2", default-features = false, features = ["wayland-data-control"] } base64 = "=0.22.0" cbc = { version = "=0.1.2", features = ["alloc"] } napi = { version = "=2.16.0", features = ["async"] } From b338e14623bb263d196196ba0e055d788a43f0d1 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Tue, 2 Apr 2024 08:18:34 -0500 Subject: [PATCH 12/18] LocalBackedSessionStorage Updates (#8542) --- .../browser/src/background/main.background.ts | 27 +++-- .../storage-service.factory.ts | 24 +++- ...cal-backed-session-storage.service.spec.ts | 104 +++++++++++------- .../local-backed-session-storage.service.ts | 94 +++++++++++----- 4 files changed, 162 insertions(+), 87 deletions(-) diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 25befdcf800b..102dad80a768 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -207,6 +207,7 @@ import { BrowserStateService as StateServiceAbstraction } from "../platform/serv import { BrowserCryptoService } from "../platform/services/browser-crypto.service"; import { BrowserEnvironmentService } from "../platform/services/browser-environment.service"; import BrowserLocalStorageService from "../platform/services/browser-local-storage.service"; +import BrowserMemoryStorageService from "../platform/services/browser-memory-storage.service"; import BrowserMessagingPrivateModeBackgroundService from "../platform/services/browser-messaging-private-mode-background.service"; import BrowserMessagingService from "../platform/services/browser-messaging.service"; import { BrowserStateService } from "../platform/services/browser-state.service"; @@ -230,7 +231,7 @@ import RuntimeBackground from "./runtime.background"; export default class MainBackground { messagingService: MessagingServiceAbstraction; - storageService: AbstractStorageService; + storageService: AbstractStorageService & ObservableStorageService; secureStorageService: AbstractStorageService; memoryStorageService: AbstractMemoryStorageService; memoryStorageForStateProviders: AbstractMemoryStorageService & ObservableStorageService; @@ -365,22 +366,28 @@ export default class MainBackground { this.cryptoFunctionService = new WebCryptoFunctionService(self); this.keyGenerationService = new KeyGenerationService(this.cryptoFunctionService); this.storageService = new BrowserLocalStorageService(); + + const mv3MemoryStorageCreator = (partitionName: string) => { + // TODO: Consider using multithreaded encrypt service in popup only context + return new LocalBackedSessionStorageService( + new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false), + this.keyGenerationService, + new BrowserLocalStorageService(), + new BrowserMemoryStorageService(), + partitionName, + ); + }; + this.secureStorageService = this.storageService; // secure storage is not supported in browsers, so we use local storage and warn users when it is used this.memoryStorageService = BrowserApi.isManifestVersion(3) - ? new LocalBackedSessionStorageService( - new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false), - this.keyGenerationService, - ) + ? mv3MemoryStorageCreator("stateService") : new MemoryStorageService(); this.memoryStorageForStateProviders = BrowserApi.isManifestVersion(3) - ? new LocalBackedSessionStorageService( - new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, false), - this.keyGenerationService, - ) + ? mv3MemoryStorageCreator("stateProviders") : new BackgroundMemoryStorageService(); const storageServiceProvider = new StorageServiceProvider( - this.storageService as BrowserLocalStorageService, + this.storageService, this.memoryStorageForStateProviders, ); diff --git a/apps/browser/src/platform/background/service-factories/storage-service.factory.ts b/apps/browser/src/platform/background/service-factories/storage-service.factory.ts index 6a854255f5e2..19d5a9c14035 100644 --- a/apps/browser/src/platform/background/service-factories/storage-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/storage-service.factory.ts @@ -7,6 +7,7 @@ import { MemoryStorageService } from "@bitwarden/common/platform/services/memory import { BrowserApi } from "../../browser/browser-api"; import BrowserLocalStorageService from "../../services/browser-local-storage.service"; +import BrowserMemoryStorageService from "../../services/browser-memory-storage.service"; import { LocalBackedSessionStorageService } from "../../services/local-backed-session-storage.service"; import { BackgroundMemoryStorageService } from "../../storage/background-memory-storage.service"; @@ -17,13 +18,14 @@ import { keyGenerationServiceFactory, } from "./key-generation-service.factory"; -type StorageServiceFactoryOptions = FactoryOptions; - -export type DiskStorageServiceInitOptions = StorageServiceFactoryOptions; -export type SecureStorageServiceInitOptions = StorageServiceFactoryOptions; -export type MemoryStorageServiceInitOptions = StorageServiceFactoryOptions & +export type DiskStorageServiceInitOptions = FactoryOptions; +export type SecureStorageServiceInitOptions = FactoryOptions; +export type SessionStorageServiceInitOptions = FactoryOptions; +export type MemoryStorageServiceInitOptions = FactoryOptions & EncryptServiceInitOptions & - KeyGenerationServiceInitOptions; + KeyGenerationServiceInitOptions & + DiskStorageServiceInitOptions & + SessionStorageServiceInitOptions; export function diskStorageServiceFactory( cache: { diskStorageService?: AbstractStorageService } & CachedServices, @@ -47,6 +49,13 @@ export function secureStorageServiceFactory( return factory(cache, "secureStorageService", opts, () => new BrowserLocalStorageService()); } +export function sessionStorageServiceFactory( + cache: { sessionStorageService?: AbstractStorageService } & CachedServices, + opts: SessionStorageServiceInitOptions, +): Promise { + return factory(cache, "sessionStorageService", opts, () => new BrowserMemoryStorageService()); +} + export function memoryStorageServiceFactory( cache: { memoryStorageService?: AbstractMemoryStorageService } & CachedServices, opts: MemoryStorageServiceInitOptions, @@ -56,6 +65,9 @@ export function memoryStorageServiceFactory( return new LocalBackedSessionStorageService( await encryptServiceFactory(cache, opts), await keyGenerationServiceFactory(cache, opts), + await diskStorageServiceFactory(cache, opts), + await sessionStorageServiceFactory(cache, opts), + "serviceFactories", ); } return new MemoryStorageService(); diff --git a/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts b/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts index fff9f2c28f36..7740a22071df 100644 --- a/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts +++ b/apps/browser/src/platform/services/local-backed-session-storage.service.spec.ts @@ -2,45 +2,70 @@ import { mock, MockProxy } from "jest-mock-extended"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; +import { + AbstractMemoryStorageService, + AbstractStorageService, + StorageUpdate, +} from "@bitwarden/common/platform/abstractions/storage.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import BrowserLocalStorageService from "./browser-local-storage.service"; -import BrowserMemoryStorageService from "./browser-memory-storage.service"; import { LocalBackedSessionStorageService } from "./local-backed-session-storage.service"; -describe("Browser Session Storage Service", () => { +describe("LocalBackedSessionStorage", () => { let encryptService: MockProxy; let keyGenerationService: MockProxy; + let localStorageService: MockProxy; + let sessionStorageService: MockProxy; let cache: Map; const testObj = { a: 1, b: 2 }; - let localStorage: BrowserLocalStorageService; - let sessionStorage: BrowserMemoryStorageService; - const key = new SymmetricCryptoKey(Utils.fromUtf8ToArray("00000000000000000000000000000000")); let getSessionKeySpy: jest.SpyInstance; + let sendUpdateSpy: jest.SpyInstance; const mockEnc = (input: string) => Promise.resolve(new EncString("ENCRYPTED" + input)); let sut: LocalBackedSessionStorageService; + const mockExistingSessionKey = (key: SymmetricCryptoKey) => { + sessionStorageService.get.mockImplementation((storageKey) => { + if (storageKey === "localEncryptionKey_test") { + return Promise.resolve(key?.toJSON()); + } + + return Promise.reject("No implementation for " + storageKey); + }); + }; + beforeEach(() => { encryptService = mock(); keyGenerationService = mock(); + localStorageService = mock(); + sessionStorageService = mock(); - sut = new LocalBackedSessionStorageService(encryptService, keyGenerationService); + sut = new LocalBackedSessionStorageService( + encryptService, + keyGenerationService, + localStorageService, + sessionStorageService, + "test", + ); cache = sut["cache"]; - localStorage = sut["localStorage"]; - sessionStorage = sut["sessionStorage"]; + + keyGenerationService.createKeyWithPurpose.mockResolvedValue({ + derivedKey: key, + salt: "bitwarden-ephemeral", + material: null, // Not used + }); + getSessionKeySpy = jest.spyOn(sut, "getSessionEncKey"); getSessionKeySpy.mockResolvedValue(key); - }); - it("should exist", () => { - expect(sut).toBeInstanceOf(LocalBackedSessionStorageService); + sendUpdateSpy = jest.spyOn(sut, "sendUpdate"); + sendUpdateSpy.mockReturnValue(); }); describe("get", () => { @@ -54,7 +79,7 @@ describe("Browser Session Storage Service", () => { const session = { test: testObj }; beforeEach(() => { - jest.spyOn(sut, "getSessionEncKey").mockResolvedValue(key); + mockExistingSessionKey(key); }); describe("no session retrieved", () => { @@ -62,6 +87,7 @@ describe("Browser Session Storage Service", () => { let spy: jest.SpyInstance; beforeEach(async () => { spy = jest.spyOn(sut, "getLocalSession").mockResolvedValue(null); + localStorageService.get.mockResolvedValue(null); result = await sut.get("test"); }); @@ -123,31 +149,31 @@ describe("Browser Session Storage Service", () => { describe("remove", () => { it("should save null", async () => { - const spy = jest.spyOn(sut, "save"); - spy.mockResolvedValue(null); await sut.remove("test"); - expect(spy).toHaveBeenCalledWith("test", null); + expect(sendUpdateSpy).toHaveBeenCalledWith({ key: "test", updateType: "remove" }); }); }); describe("save", () => { describe("caching", () => { beforeEach(() => { - jest.spyOn(localStorage, "get").mockResolvedValue(null); - jest.spyOn(sessionStorage, "get").mockResolvedValue(null); - jest.spyOn(localStorage, "save").mockResolvedValue(); - jest.spyOn(sessionStorage, "save").mockResolvedValue(); + localStorageService.get.mockResolvedValue(null); + sessionStorageService.get.mockResolvedValue(null); + + localStorageService.save.mockResolvedValue(); + sessionStorageService.save.mockResolvedValue(); encryptService.encrypt.mockResolvedValue(mockEnc("{}")); }); it("should remove key from cache if value is null", async () => { cache.set("test", {}); - const deleteSpy = jest.spyOn(cache, "delete"); + const cacheSetSpy = jest.spyOn(cache, "set"); expect(cache.has("test")).toBe(true); await sut.save("test", null); - expect(cache.has("test")).toBe(false); - expect(deleteSpy).toHaveBeenCalledWith("test"); + // Don't remove from cache, just replace with null + expect(cache.get("test")).toBe(null); + expect(cacheSetSpy).toHaveBeenCalledWith("test", null); }); it("should set cache if value is non-null", async () => { @@ -197,7 +223,7 @@ describe("Browser Session Storage Service", () => { }); it("should return the stored symmetric crypto key", async () => { - jest.spyOn(sessionStorage, "get").mockResolvedValue({ ...key }); + sessionStorageService.get.mockResolvedValue({ ...key }); const result = await sut.getSessionEncKey(); expect(result).toStrictEqual(key); @@ -205,7 +231,6 @@ describe("Browser Session Storage Service", () => { describe("new key creation", () => { beforeEach(() => { - jest.spyOn(sessionStorage, "get").mockResolvedValue(null); keyGenerationService.createKeyWithPurpose.mockResolvedValue({ salt: "salt", material: null, @@ -218,25 +243,24 @@ describe("Browser Session Storage Service", () => { const result = await sut.getSessionEncKey(); expect(result).toStrictEqual(key); - expect(keyGenerationService.createKeyWithPurpose).toBeCalledTimes(1); + expect(keyGenerationService.createKeyWithPurpose).toHaveBeenCalledTimes(1); }); it("should store a symmetric crypto key if it makes one", async () => { const spy = jest.spyOn(sut, "setSessionEncKey").mockResolvedValue(); await sut.getSessionEncKey(); - expect(spy).toBeCalledWith(key); + expect(spy).toHaveBeenCalledWith(key); }); }); }); describe("getLocalSession", () => { it("should return null if session is null", async () => { - const spy = jest.spyOn(localStorage, "get").mockResolvedValue(null); const result = await sut.getLocalSession(key); expect(result).toBeNull(); - expect(spy).toBeCalledWith("session"); + expect(localStorageService.get).toHaveBeenCalledWith("session_test"); }); describe("non-null sessions", () => { @@ -245,7 +269,7 @@ describe("Browser Session Storage Service", () => { const decryptedSession = JSON.stringify(session); beforeEach(() => { - jest.spyOn(localStorage, "get").mockResolvedValue(encSession.encryptedString); + localStorageService.get.mockResolvedValue(encSession.encryptedString); }); it("should decrypt returned sessions", async () => { @@ -267,13 +291,12 @@ describe("Browser Session Storage Service", () => { it("should remove state if decryption fails", async () => { encryptService.decryptToUtf8.mockResolvedValue(null); const setSessionEncKeySpy = jest.spyOn(sut, "setSessionEncKey").mockResolvedValue(); - const removeLocalSessionSpy = jest.spyOn(localStorage, "remove").mockResolvedValue(); const result = await sut.getLocalSession(key); expect(result).toBeNull(); expect(setSessionEncKeySpy).toHaveBeenCalledWith(null); - expect(removeLocalSessionSpy).toHaveBeenCalledWith("session"); + expect(localStorageService.remove).toHaveBeenCalledWith("session_test"); }); }); }); @@ -284,7 +307,7 @@ describe("Browser Session Storage Service", () => { it("should encrypt a stringified session", async () => { encryptService.encrypt.mockImplementation(mockEnc); - jest.spyOn(localStorage, "save").mockResolvedValue(); + localStorageService.save.mockResolvedValue(); await sut.setLocalSession(testSession, key); expect(encryptService.encrypt).toHaveBeenNthCalledWith(1, testJSON, key); @@ -292,32 +315,31 @@ describe("Browser Session Storage Service", () => { it("should remove local session if null", async () => { encryptService.encrypt.mockResolvedValue(null); - const spy = jest.spyOn(localStorage, "remove").mockResolvedValue(); await sut.setLocalSession(null, key); - expect(spy).toHaveBeenCalledWith("session"); + expect(localStorageService.remove).toHaveBeenCalledWith("session_test"); }); it("should save encrypted string", async () => { encryptService.encrypt.mockImplementation(mockEnc); - const spy = jest.spyOn(localStorage, "save").mockResolvedValue(); await sut.setLocalSession(testSession, key); - expect(spy).toHaveBeenCalledWith("session", (await mockEnc(testJSON)).encryptedString); + expect(localStorageService.save).toHaveBeenCalledWith( + "session_test", + (await mockEnc(testJSON)).encryptedString, + ); }); }); describe("setSessionKey", () => { it("should remove if null", async () => { - const spy = jest.spyOn(sessionStorage, "remove").mockResolvedValue(); await sut.setSessionEncKey(null); - expect(spy).toHaveBeenCalledWith("localEncryptionKey"); + expect(sessionStorageService.remove).toHaveBeenCalledWith("localEncryptionKey_test"); }); it("should save key when not null", async () => { - const spy = jest.spyOn(sessionStorage, "save").mockResolvedValue(); await sut.setSessionEncKey(key); - expect(spy).toHaveBeenCalledWith("localEncryptionKey", key); + expect(sessionStorageService.save).toHaveBeenCalledWith("localEncryptionKey_test", key); }); }); }); diff --git a/apps/browser/src/platform/services/local-backed-session-storage.service.ts b/apps/browser/src/platform/services/local-backed-session-storage.service.ts index b2823ffe4b45..3f01e4169e97 100644 --- a/apps/browser/src/platform/services/local-backed-session-storage.service.ts +++ b/apps/browser/src/platform/services/local-backed-session-storage.service.ts @@ -1,40 +1,60 @@ -import { Subject } from "rxjs"; +import { Observable, Subject, filter, map, merge, share, tap } from "rxjs"; import { Jsonify } from "type-fest"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; import { AbstractMemoryStorageService, + AbstractStorageService, + ObservableStorageService, StorageUpdate, } from "@bitwarden/common/platform/abstractions/storage.service"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { MemoryStorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { fromChromeEvent } from "../browser/from-chrome-event"; import { devFlag } from "../decorators/dev-flag.decorator"; import { devFlagEnabled } from "../flags"; -import BrowserLocalStorageService from "./browser-local-storage.service"; -import BrowserMemoryStorageService from "./browser-memory-storage.service"; - -const keys = { - encKey: "localEncryptionKey", - sessionKey: "session", -}; - -export class LocalBackedSessionStorageService extends AbstractMemoryStorageService { +export class LocalBackedSessionStorageService + extends AbstractMemoryStorageService + implements ObservableStorageService +{ private cache = new Map(); - private localStorage = new BrowserLocalStorageService(); - private sessionStorage = new BrowserMemoryStorageService(); private updatesSubject = new Subject(); - updates$; + + private commandName = `localBackedSessionStorage_${this.name}`; + private encKey = `localEncryptionKey_${this.name}`; + private sessionKey = `session_${this.name}`; + + updates$: Observable; constructor( private encryptService: EncryptService, private keyGenerationService: KeyGenerationService, + private localStorage: AbstractStorageService, + private sessionStorage: AbstractStorageService, + private name: string, ) { super(); - this.updates$ = this.updatesSubject.asObservable(); + + const remoteObservable = fromChromeEvent(chrome.runtime.onMessage).pipe( + filter(([msg]) => msg.command === this.commandName), + map(([msg]) => msg.update as StorageUpdate), + tap((update) => { + if (update.updateType === "remove") { + this.cache.set(update.key, null); + } else { + this.cache.delete(update.key); + } + }), + share(), + ); + + remoteObservable.subscribe(); + + this.updates$ = merge(this.updatesSubject.asObservable(), remoteObservable); } get valuesRequireDeserialization(): boolean { @@ -70,23 +90,37 @@ export class LocalBackedSessionStorageService extends AbstractMemoryStorageServi async save(key: string, obj: T): Promise { if (obj == null) { - this.cache.delete(key); - } else { - this.cache.set(key, obj); + return await this.remove(key); } + this.cache.set(key, obj); + await this.updateLocalSessionValue(key, obj); + this.sendUpdate({ key, updateType: "save" }); + } + + async remove(key: string): Promise { + this.cache.set(key, null); + await this.updateLocalSessionValue(key, null); + this.sendUpdate({ key, updateType: "remove" }); + } + + sendUpdate(storageUpdate: StorageUpdate) { + this.updatesSubject.next(storageUpdate); + void chrome.runtime.sendMessage({ + command: this.commandName, + update: storageUpdate, + }); + } + + private async updateLocalSessionValue(key: string, obj: T) { const sessionEncKey = await this.getSessionEncKey(); const localSession = (await this.getLocalSession(sessionEncKey)) ?? {}; localSession[key] = obj; await this.setLocalSession(localSession, sessionEncKey); } - async remove(key: string): Promise { - await this.save(key, null); - } - async getLocalSession(encKey: SymmetricCryptoKey): Promise> { - const local = await this.localStorage.get(keys.sessionKey); + const local = await this.localStorage.get(this.sessionKey); if (local == null) { return null; @@ -100,7 +134,7 @@ export class LocalBackedSessionStorageService extends AbstractMemoryStorageServi if (sessionJson == null) { // Error with decryption -- session is lost, delete state and key and start over await this.setSessionEncKey(null); - await this.localStorage.remove(keys.sessionKey); + await this.localStorage.remove(this.sessionKey); return null; } return JSON.parse(sessionJson); @@ -119,9 +153,9 @@ export class LocalBackedSessionStorageService extends AbstractMemoryStorageServi // Make sure we're storing the jsonified version of the session const jsonSession = JSON.parse(JSON.stringify(session)); if (session == null) { - await this.localStorage.remove(keys.sessionKey); + await this.localStorage.remove(this.sessionKey); } else { - await this.localStorage.save(keys.sessionKey, jsonSession); + await this.localStorage.save(this.sessionKey, jsonSession); } } @@ -130,13 +164,13 @@ export class LocalBackedSessionStorageService extends AbstractMemoryStorageServi const encSession = await this.encryptService.encrypt(jsonSession, key); if (encSession == null) { - return await this.localStorage.remove(keys.sessionKey); + return await this.localStorage.remove(this.sessionKey); } - await this.localStorage.save(keys.sessionKey, encSession.encryptedString); + await this.localStorage.save(this.sessionKey, encSession.encryptedString); } async getSessionEncKey(): Promise { - let storedKey = await this.sessionStorage.get(keys.encKey); + let storedKey = await this.sessionStorage.get(this.encKey); if (storedKey == null || Object.keys(storedKey).length == 0) { const generatedKey = await this.keyGenerationService.createKeyWithPurpose( 128, @@ -153,9 +187,9 @@ export class LocalBackedSessionStorageService extends AbstractMemoryStorageServi async setSessionEncKey(input: SymmetricCryptoKey): Promise { if (input == null) { - await this.sessionStorage.remove(keys.encKey); + await this.sessionStorage.remove(this.encKey); } else { - await this.sessionStorage.save(keys.encKey, input); + await this.sessionStorage.save(this.encKey, input); } } } From 22cca018f83a806b77a48efc59ac578c706ee5f6 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 2 Apr 2024 09:28:36 -0400 Subject: [PATCH 13/18] Don't let users who can't edit sub reactivate sub pending cancelation (#8433) --- .../organization-subscription-cloud.component.html | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html index 7f53fba1c03f..5f767d85c464 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.html @@ -25,7 +25,13 @@ *ngIf="subscriptionMarkedForCancel" >

{{ "subscriptionPendingCanceled" | i18n }}

- From a201e9cff13b58210bff50d3353462ff040fee41 Mon Sep 17 00:00:00 2001 From: Will Martin Date: Tue, 2 Apr 2024 10:21:01 -0400 Subject: [PATCH 14/18] [PM-7229] Fix circular dependency in ErrorHandler (#8573) * call injector explicitly in ErrorHandler * Fallback to `super` on early error Co-authored-by: Matt Gibson --------- Co-authored-by: Matt Gibson , --- .../platform/services/logging-error-handler.ts | 18 +++++++++++++----- .../src/services/jslib-services.module.ts | 2 +- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/libs/angular/src/platform/services/logging-error-handler.ts b/libs/angular/src/platform/services/logging-error-handler.ts index 81cd537e7f13..522412dd288e 100644 --- a/libs/angular/src/platform/services/logging-error-handler.ts +++ b/libs/angular/src/platform/services/logging-error-handler.ts @@ -1,14 +1,22 @@ -import { ErrorHandler, Injectable } from "@angular/core"; +import { ErrorHandler, Injectable, Injector, inject } from "@angular/core"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @Injectable() export class LoggingErrorHandler extends ErrorHandler { - constructor(private readonly logService: LogService) { - super(); - } + /** + * When injecting services into an `ErrorHandler`, we must use the `Injector` manually to avoid circular dependency errors. + * + * https://stackoverflow.com/a/57115053 + */ + private injector = inject(Injector); override handleError(error: any): void { - this.logService.error(error); + try { + const logService = this.injector.get(LogService, null); + logService.error(error); + } catch { + super.handleError(error); + } } } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 114c60c19367..6378eed75541 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -1075,7 +1075,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: ErrorHandler, useClass: LoggingErrorHandler, - deps: [LogService], + deps: [], }), ]; From af5f45443d870cf6012c3bdcdb6180786c951b30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Gon=C3=A7alves?= Date: Tue, 2 Apr 2024 16:23:05 +0100 Subject: [PATCH 15/18] [PM-5434] Create VaultBrowserStateService and migrate components from BrowserStateService (#8017) * PM-5434 Initial work on migration * PM-5434 Migration and tests * PM-5434 Remove unnecessary comments * PM-5434 Add unit tests * PM-5434 Reverted last changes * PM-5434 Added unit test for deserialize * PM-5434 Minor changes * PM-5434 Fix pr comments --- .../abstractions/browser-state.service.ts | 13 --- .../services/browser-state.service.spec.ts | 22 ----- .../services/browser-state.service.ts | 45 ---------- apps/browser/src/popup/app.component.ts | 6 +- .../src/popup/services/services.module.ts | 8 ++ .../vault/vault-filter.component.ts | 12 +-- .../components/vault/vault-items.component.ts | 4 +- .../vault-browser-state.service.spec.ts | 87 +++++++++++++++++++ .../services/vault-browser-state.service.ts | 65 ++++++++++++++ .../src/platform/state/state-definitions.ts | 1 + 10 files changed, 173 insertions(+), 90 deletions(-) create mode 100644 apps/browser/src/vault/services/vault-browser-state.service.spec.ts create mode 100644 apps/browser/src/vault/services/vault-browser-state.service.ts diff --git a/apps/browser/src/platform/services/abstractions/browser-state.service.ts b/apps/browser/src/platform/services/abstractions/browser-state.service.ts index 88c2312762b9..82ec54975ae1 100644 --- a/apps/browser/src/platform/services/abstractions/browser-state.service.ts +++ b/apps/browser/src/platform/services/abstractions/browser-state.service.ts @@ -3,22 +3,9 @@ import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage import { Account } from "../../../models/account"; import { BrowserComponentState } from "../../../models/browserComponentState"; -import { BrowserGroupingsComponentState } from "../../../models/browserGroupingsComponentState"; import { BrowserSendComponentState } from "../../../models/browserSendComponentState"; export abstract class BrowserStateService extends BaseStateServiceAbstraction { - getBrowserGroupingComponentState: ( - options?: StorageOptions, - ) => Promise; - setBrowserGroupingComponentState: ( - value: BrowserGroupingsComponentState, - options?: StorageOptions, - ) => Promise; - getBrowserVaultItemsComponentState: (options?: StorageOptions) => Promise; - setBrowserVaultItemsComponentState: ( - value: BrowserComponentState, - options?: StorageOptions, - ) => Promise; getBrowserSendComponentState: (options?: StorageOptions) => Promise; setBrowserSendComponentState: ( value: BrowserSendComponentState, diff --git a/apps/browser/src/platform/services/browser-state.service.spec.ts b/apps/browser/src/platform/services/browser-state.service.spec.ts index 3069b8f1749b..7e75b9b70773 100644 --- a/apps/browser/src/platform/services/browser-state.service.spec.ts +++ b/apps/browser/src/platform/services/browser-state.service.spec.ts @@ -18,7 +18,6 @@ import { UserId } from "@bitwarden/common/types/guid"; import { Account } from "../../models/account"; import { BrowserComponentState } from "../../models/browserComponentState"; -import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState"; import { BrowserSendComponentState } from "../../models/browserSendComponentState"; import { BrowserStateService } from "./browser-state.service"; @@ -86,27 +85,6 @@ describe("Browser State Service", () => { ); }); - describe("getBrowserGroupingComponentState", () => { - it("should return a BrowserGroupingsComponentState", async () => { - state.accounts[userId].groupings = new BrowserGroupingsComponentState(); - - const actual = await sut.getBrowserGroupingComponentState(); - expect(actual).toBeInstanceOf(BrowserGroupingsComponentState); - }); - }); - - describe("getBrowserVaultItemsComponentState", () => { - it("should return a BrowserComponentState", async () => { - const componentState = new BrowserComponentState(); - componentState.scrollY = 0; - componentState.searchText = "test"; - state.accounts[userId].ciphers = componentState; - - const actual = await sut.getBrowserVaultItemsComponentState(); - expect(actual).toStrictEqual(componentState); - }); - }); - describe("getBrowserSendComponentState", () => { it("should return a BrowserSendComponentState", async () => { const sendState = new BrowserSendComponentState(); diff --git a/apps/browser/src/platform/services/browser-state.service.ts b/apps/browser/src/platform/services/browser-state.service.ts index f7ee74be2179..ea410ee83ab3 100644 --- a/apps/browser/src/platform/services/browser-state.service.ts +++ b/apps/browser/src/platform/services/browser-state.service.ts @@ -16,7 +16,6 @@ import { StateService as BaseStateService } from "@bitwarden/common/platform/ser import { Account } from "../../models/account"; import { BrowserComponentState } from "../../models/browserComponentState"; -import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState"; import { BrowserSendComponentState } from "../../models/browserSendComponentState"; import { BrowserApi } from "../browser/browser-api"; import { browserSession, sessionSync } from "../decorators/session-sync-observable"; @@ -116,50 +115,6 @@ export class BrowserStateService ); } - async getBrowserGroupingComponentState( - options?: StorageOptions, - ): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) - )?.groupings; - } - - async setBrowserGroupingComponentState( - value: BrowserGroupingsComponentState, - options?: StorageOptions, - ): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - account.groupings = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - } - - async getBrowserVaultItemsComponentState( - options?: StorageOptions, - ): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) - )?.ciphers; - } - - async setBrowserVaultItemsComponentState( - value: BrowserComponentState, - options?: StorageOptions, - ): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - account.ciphers = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - } - async getBrowserSendComponentState(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 9aa438d3b3ba..e0d898481bb0 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -12,6 +12,7 @@ import { BrowserApi } from "../platform/browser/browser-api"; import { ZonedMessageListenerService } from "../platform/browser/zoned-message-listener.service"; import { BrowserStateService } from "../platform/services/abstractions/browser-state.service"; import { ForegroundPlatformUtilsService } from "../platform/services/platform-utils/foreground-platform-utils.service"; +import { VaultBrowserStateService } from "../vault/services/vault-browser-state.service"; import { routerTransition } from "./app-routing.animations"; import { DesktopSyncVerificationDialogComponent } from "./components/desktop-sync-verification-dialog.component"; @@ -37,6 +38,7 @@ export class AppComponent implements OnInit, OnDestroy { private i18nService: I18nService, private router: Router, private stateService: BrowserStateService, + private vaultBrowserStateService: VaultBrowserStateService, private changeDetectorRef: ChangeDetectorRef, private ngZone: NgZone, private platformUtilsService: ForegroundPlatformUtilsService, @@ -227,8 +229,8 @@ export class AppComponent implements OnInit, OnDestroy { } await Promise.all([ - this.stateService.setBrowserGroupingComponentState(null), - this.stateService.setBrowserVaultItemsComponentState(null), + this.vaultBrowserStateService.setBrowserGroupingsComponentState(null), + this.vaultBrowserStateService.setBrowserVaultItemsComponentState(null), this.stateService.setBrowserSendComponentState(null), this.stateService.setBrowserSendTypeComponentState(null), ]); diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index fbeabca4621c..6d0f73f2067c 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -102,6 +102,7 @@ import { ForegroundPlatformUtilsService } from "../../platform/services/platform import { ForegroundDerivedStateProvider } from "../../platform/state/foreground-derived-state.provider"; import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service"; import { FilePopoutUtilsService } from "../../tools/popup/services/file-popout-utils.service"; +import { VaultBrowserStateService } from "../../vault/services/vault-browser-state.service"; import { VaultFilterService } from "../../vault/services/vault-filter.service"; import { DebounceNavigationService } from "./debounce-navigation.service"; @@ -377,6 +378,13 @@ const safeProviders: SafeProvider[] = [ provide: OBSERVABLE_DISK_STORAGE, useExisting: AbstractStorageService, }), + safeProvider({ + provide: VaultBrowserStateService, + useFactory: (stateProvider: StateProvider) => { + return new VaultBrowserStateService(stateProvider); + }, + deps: [StateProvider], + }), safeProvider({ provide: StateServiceAbstraction, useFactory: ( diff --git a/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts b/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts index 5e7959b38f85..2510e2f966b6 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-filter.component.ts @@ -20,7 +20,7 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { BrowserGroupingsComponentState } from "../../../../models/browserGroupingsComponentState"; import { BrowserApi } from "../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils"; -import { BrowserStateService } from "../../../../platform/services/abstractions/browser-state.service"; +import { VaultBrowserStateService } from "../../../services/vault-browser-state.service"; import { VaultFilterService } from "../../../services/vault-filter.service"; const ComponentId = "VaultComponent"; @@ -84,8 +84,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy { private platformUtilsService: PlatformUtilsService, private searchService: SearchService, private location: Location, - private browserStateService: BrowserStateService, private vaultFilterService: VaultFilterService, + private vaultBrowserStateService: VaultBrowserStateService, ) { this.noFolderListSize = 100; } @@ -95,7 +95,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { this.showLeftHeader = !( BrowserPopupUtils.inSidebar(window) && this.platformUtilsService.isFirefox() ); - await this.browserStateService.setBrowserVaultItemsComponentState(null); + await this.vaultBrowserStateService.setBrowserVaultItemsComponentState(null); this.broadcasterService.subscribe(ComponentId, (message: any) => { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. @@ -120,7 +120,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { const restoredScopeState = await this.restoreState(); // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe this.route.queryParams.pipe(first()).subscribe(async (params) => { - this.state = await this.browserStateService.getBrowserGroupingComponentState(); + this.state = await this.vaultBrowserStateService.getBrowserGroupingsComponentState(); if (this.state?.searchText) { this.searchText = this.state.searchText; } else if (params.searchText) { @@ -413,11 +413,11 @@ export class VaultFilterComponent implements OnInit, OnDestroy { collections: this.collections, deletedCount: this.deletedCount, }); - await this.browserStateService.setBrowserGroupingComponentState(this.state); + await this.vaultBrowserStateService.setBrowserGroupingsComponentState(this.state); } private async restoreState(): Promise { - this.state = await this.browserStateService.getBrowserGroupingComponentState(); + this.state = await this.vaultBrowserStateService.getBrowserGroupingsComponentState(); if (this.state == null) { return false; } diff --git a/apps/browser/src/vault/popup/components/vault/vault-items.component.ts b/apps/browser/src/vault/popup/components/vault/vault-items.component.ts index 96d5fe170b02..abb810c04d58 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-items.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-items.component.ts @@ -21,7 +21,7 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { BrowserComponentState } from "../../../../models/browserComponentState"; import { BrowserApi } from "../../../../platform/browser/browser-api"; import BrowserPopupUtils from "../../../../platform/popup/browser-popup-utils"; -import { BrowserStateService } from "../../../../platform/services/abstractions/browser-state.service"; +import { VaultBrowserStateService } from "../../../services/vault-browser-state.service"; import { VaultFilterService } from "../../../services/vault-filter.service"; const ComponentId = "VaultItemsComponent"; @@ -59,7 +59,7 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn private ngZone: NgZone, private broadcasterService: BroadcasterService, private changeDetectorRef: ChangeDetectorRef, - private stateService: BrowserStateService, + private stateService: VaultBrowserStateService, private i18nService: I18nService, private collectionService: CollectionService, private platformUtilsService: PlatformUtilsService, diff --git a/apps/browser/src/vault/services/vault-browser-state.service.spec.ts b/apps/browser/src/vault/services/vault-browser-state.service.spec.ts new file mode 100644 index 000000000000..b9369aa826b2 --- /dev/null +++ b/apps/browser/src/vault/services/vault-browser-state.service.spec.ts @@ -0,0 +1,87 @@ +import { + FakeAccountService, + mockAccountServiceWith, +} from "@bitwarden/common/../spec/fake-account-service"; +import { FakeStateProvider } from "@bitwarden/common/../spec/fake-state-provider"; +import { Jsonify } from "type-fest"; + +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherType } from "@bitwarden/common/vault/enums"; + +import { BrowserComponentState } from "../../models/browserComponentState"; +import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState"; + +import { + VAULT_BROWSER_COMPONENT, + VAULT_BROWSER_GROUPINGS_COMPONENT, + VaultBrowserStateService, +} from "./vault-browser-state.service"; + +describe("Vault Browser State Service", () => { + let stateProvider: FakeStateProvider; + + let accountService: FakeAccountService; + let stateService: VaultBrowserStateService; + const mockUserId = Utils.newGuid() as UserId; + + beforeEach(() => { + accountService = mockAccountServiceWith(mockUserId); + stateProvider = new FakeStateProvider(accountService); + + stateService = new VaultBrowserStateService(stateProvider); + }); + + describe("getBrowserGroupingsComponentState", () => { + it("should return a BrowserGroupingsComponentState", async () => { + await stateService.setBrowserGroupingsComponentState(new BrowserGroupingsComponentState()); + + const actual = await stateService.getBrowserGroupingsComponentState(); + + expect(actual).toBeInstanceOf(BrowserGroupingsComponentState); + }); + + it("should deserialize BrowserGroupingsComponentState", () => { + const sut = VAULT_BROWSER_GROUPINGS_COMPONENT; + + const expectedState = { + deletedCount: 0, + collectionCounts: new Map(), + folderCounts: new Map(), + typeCounts: new Map(), + }; + + const result = sut.deserializer( + JSON.parse(JSON.stringify(expectedState)) as Jsonify, + ); + + expect(result).toEqual(expectedState); + }); + }); + + describe("getBrowserVaultItemsComponentState", () => { + it("should deserialize BrowserComponentState", () => { + const sut = VAULT_BROWSER_COMPONENT; + + const expectedState = { + scrollY: 0, + searchText: "test", + }; + + const result = sut.deserializer(JSON.parse(JSON.stringify(expectedState))); + + expect(result).toEqual(expectedState); + }); + + it("should return a BrowserComponentState", async () => { + const componentState = new BrowserComponentState(); + componentState.scrollY = 0; + componentState.searchText = "test"; + + await stateService.setBrowserVaultItemsComponentState(componentState); + + const actual = await stateService.getBrowserVaultItemsComponentState(); + expect(actual).toStrictEqual(componentState); + }); + }); +}); diff --git a/apps/browser/src/vault/services/vault-browser-state.service.ts b/apps/browser/src/vault/services/vault-browser-state.service.ts new file mode 100644 index 000000000000..a0d55a9d550b --- /dev/null +++ b/apps/browser/src/vault/services/vault-browser-state.service.ts @@ -0,0 +1,65 @@ +import { Observable, firstValueFrom } from "rxjs"; +import { Jsonify } from "type-fest"; + +import { + ActiveUserState, + KeyDefinition, + StateProvider, + VAULT_BROWSER_MEMORY, +} from "@bitwarden/common/platform/state"; + +import { BrowserComponentState } from "../../models/browserComponentState"; +import { BrowserGroupingsComponentState } from "../../models/browserGroupingsComponentState"; + +export const VAULT_BROWSER_GROUPINGS_COMPONENT = new KeyDefinition( + VAULT_BROWSER_MEMORY, + "vault_browser_groupings_component", + { + deserializer: (obj: Jsonify) => + BrowserGroupingsComponentState.fromJSON(obj), + }, +); + +export const VAULT_BROWSER_COMPONENT = new KeyDefinition( + VAULT_BROWSER_MEMORY, + "vault_browser_component", + { + deserializer: (obj: Jsonify) => BrowserComponentState.fromJSON(obj), + }, +); + +export class VaultBrowserStateService { + vaultBrowserGroupingsComponentState$: Observable; + vaultBrowserComponentState$: Observable; + + private activeUserVaultBrowserGroupingsComponentState: ActiveUserState; + private activeUserVaultBrowserComponentState: ActiveUserState; + + constructor(protected stateProvider: StateProvider) { + this.activeUserVaultBrowserGroupingsComponentState = this.stateProvider.getActive( + VAULT_BROWSER_GROUPINGS_COMPONENT, + ); + this.activeUserVaultBrowserComponentState = + this.stateProvider.getActive(VAULT_BROWSER_COMPONENT); + + this.vaultBrowserGroupingsComponentState$ = + this.activeUserVaultBrowserGroupingsComponentState.state$; + this.vaultBrowserComponentState$ = this.activeUserVaultBrowserComponentState.state$; + } + + async getBrowserGroupingsComponentState(): Promise { + return await firstValueFrom(this.vaultBrowserGroupingsComponentState$); + } + + async setBrowserGroupingsComponentState(value: BrowserGroupingsComponentState): Promise { + await this.activeUserVaultBrowserGroupingsComponentState.update(() => value); + } + + async getBrowserVaultItemsComponentState(): Promise { + return await firstValueFrom(this.vaultBrowserComponentState$); + } + + async setBrowserVaultItemsComponentState(value: BrowserComponentState): Promise { + await this.activeUserVaultBrowserComponentState.update(() => value); + } +} diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 466c3a2c111d..9fca0e944591 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -118,3 +118,4 @@ export const VAULT_ONBOARDING = new StateDefinition("vaultOnboarding", "disk", { export const VAULT_SETTINGS_DISK = new StateDefinition("vaultSettings", "disk", { web: "disk-local", }); +export const VAULT_BROWSER_MEMORY = new StateDefinition("vaultBrowser", "memory"); From 2e6d977ef1aec00423b709de01c9c75cbec14655 Mon Sep 17 00:00:00 2001 From: Jake Fink Date: Tue, 2 Apr 2024 11:23:35 -0400 Subject: [PATCH 16/18] init observable on service (#8577) --- .../auth-request/auth-request.service.spec.ts | 17 +++++++++++++++++ .../auth-request/auth-request.service.ts | 4 +++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts b/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts index b1971f6b5269..80d00b2a01ee 100644 --- a/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts +++ b/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts @@ -2,6 +2,7 @@ import { mock } from "jest-mock-extended"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; +import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @@ -30,6 +31,22 @@ describe("AuthRequestService", () => { mockPrivateKey = new Uint8Array(64); }); + describe("authRequestPushNotification$", () => { + it("should emit when sendAuthRequestPushNotification is called", () => { + const notification = { + id: "PUSH_NOTIFICATION", + userId: "USER_ID", + } as AuthRequestPushNotification; + + const spy = jest.fn(); + sut.authRequestPushNotification$.subscribe(spy); + + sut.sendAuthRequestPushNotification(notification); + + expect(spy).toHaveBeenCalledWith("PUSH_NOTIFICATION"); + }); + }); + describe("approveOrDenyAuthRequest", () => { beforeEach(() => { cryptoService.rsaEncrypt.mockResolvedValue({ diff --git a/libs/auth/src/common/services/auth-request/auth-request.service.ts b/libs/auth/src/common/services/auth-request/auth-request.service.ts index ff33eadfba76..eb39659f53fb 100644 --- a/libs/auth/src/common/services/auth-request/auth-request.service.ts +++ b/libs/auth/src/common/services/auth-request/auth-request.service.ts @@ -22,7 +22,9 @@ export class AuthRequestService implements AuthRequestServiceAbstraction { private cryptoService: CryptoService, private apiService: ApiService, private stateService: StateService, - ) {} + ) { + this.authRequestPushNotification$ = this.authRequestPushNotificationSubject.asObservable(); + } async approveOrDenyAuthRequest( approve: boolean, From b9771c1e426746a202a4633089ce638fba10886a Mon Sep 17 00:00:00 2001 From: Cesar Gonzalez Date: Tue, 2 Apr 2024 10:24:16 -0500 Subject: [PATCH 17/18] [PM-5584] Set up a stay alive method to allow service worker in manifest v3 to stay alive indefinitely (#8535) --- apps/browser/src/platform/background.ts | 65 +++++++++++-------------- 1 file changed, 29 insertions(+), 36 deletions(-) diff --git a/apps/browser/src/platform/background.ts b/apps/browser/src/platform/background.ts index 5aa2820e5f5c..9c3510178cd6 100644 --- a/apps/browser/src/platform/background.ts +++ b/apps/browser/src/platform/background.ts @@ -1,42 +1,35 @@ +import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; + import MainBackground from "../background/main.background"; -import { onAlarmListener } from "./alarms/on-alarm-listener"; -import { registerAlarms } from "./alarms/register-alarms"; import { BrowserApi } from "./browser/browser-api"; -import { - contextMenusClickedListener, - onCommandListener, - onInstallListener, - runtimeMessageListener, - windowsOnFocusChangedListener, - tabsOnActivatedListener, - tabsOnReplacedListener, - tabsOnUpdatedListener, -} from "./listeners"; -if (BrowserApi.isManifestVersion(3)) { - chrome.commands.onCommand.addListener(onCommandListener); - chrome.runtime.onInstalled.addListener(onInstallListener); - chrome.alarms.onAlarm.addListener(onAlarmListener); - registerAlarms(); - chrome.windows.onFocusChanged.addListener(windowsOnFocusChangedListener); - chrome.tabs.onActivated.addListener(tabsOnActivatedListener); - chrome.tabs.onReplaced.addListener(tabsOnReplacedListener); - chrome.tabs.onUpdated.addListener(tabsOnUpdatedListener); - chrome.contextMenus.onClicked.addListener(contextMenusClickedListener); - BrowserApi.messageListener( - "runtime.background", - (message: { command: string }, sender, sendResponse) => { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - runtimeMessageListener(message, sender); - }, - ); -} else { - const bitwardenMain = ((self as any).bitwardenMain = new MainBackground()); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - bitwardenMain.bootstrap().then(() => { +const logService = new ConsoleLogService(false); +const bitwardenMain = ((self as any).bitwardenMain = new MainBackground()); +bitwardenMain + .bootstrap() + .then(() => { // Finished bootstrapping - }); + if (BrowserApi.isManifestVersion(3)) { + startHeartbeat().catch((error) => logService.error(error)); + } + }) + .catch((error) => logService.error(error)); + +/** + * Tracks when a service worker was last alive and extends the service worker + * lifetime by writing the current time to extension storage every 20 seconds. + */ +async function runHeartbeat() { + await chrome.storage.local.set({ "last-heartbeat": new Date().getTime() }); +} + +/** + * Starts the heartbeat interval which keeps the service worker alive. + */ +async function startHeartbeat() { + // Run the heartbeat once at service worker startup, then again every 20 seconds. + runHeartbeat() + .then(() => setInterval(runHeartbeat, 20 * 1000)) + .catch((error) => logService.error(error)); } From 9956f020e75697c1af5df823b96a479702fa5439 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Tue, 2 Apr 2024 17:04:02 +0100 Subject: [PATCH 18/18] [AC-1911] Clients: Create components to manage client organization seat allocation (#8505) * implementing the clients changes * resolve pr comments on message.json * moved the method to billing-api.service * move the request and response files to billing folder * remove the adding existing orgs * resolve the routing issue * resolving the pr comments * code owner changes * fix the assignedseat * resolve the warning message * resolve the error on update * passing the right id * resolve the unassign value * removed unused logservice * Adding the loader on submit button --- .github/CODEOWNERS | 1 + apps/web/src/locales/en/messages.json | 36 ++++ .../providers/clients/clients.component.ts | 33 +++- .../providers/providers-layout.component.html | 6 +- .../providers/providers-layout.component.ts | 5 + .../providers/providers-routing.module.ts | 7 + .../providers/providers.module.ts | 5 + ...t-organization-subscription.component.html | 49 ++++++ ...ent-organization-subscription.component.ts | 115 +++++++++++++ ...manage-client-organizations.component.html | 90 ++++++++++ .../manage-client-organizations.component.ts | 160 ++++++++++++++++++ .../billilng-api.service.abstraction.ts | 8 + .../provider-subscription-update.request.ts | 3 + .../provider-subscription-response.ts | 38 +++++ .../billing/services/billing-api.service.ts | 27 +++ libs/common/src/enums/feature-flag.enum.ts | 1 + 16 files changed, 575 insertions(+), 9 deletions(-) create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.html create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.ts create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts create mode 100644 libs/common/src/billing/models/request/provider-subscription-update.request.ts create mode 100644 libs/common/src/billing/models/response/provider-subscription-response.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bfad3f26281a..e9c1f229a51d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -61,6 +61,7 @@ apps/web/src/app/billing @bitwarden/team-billing-dev libs/angular/src/billing @bitwarden/team-billing-dev libs/common/src/billing @bitwarden/team-billing-dev libs/billing @bitwarden/team-billing-dev +bitwarden_license/bit-web/src/app/billing @bitwarden/team-billing-dev ## Platform team files ## apps/browser/src/platform @bitwarden/team-platform-dev diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 95d1b03e7250..b8e5a5ff4d5b 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -4956,6 +4956,9 @@ "addExistingOrganization": { "message": "Add existing organization" }, + "addNewOrganization": { + "message": "Add new organization" + }, "myProvider": { "message": "My Provider" }, @@ -7642,5 +7645,38 @@ }, "items": { "message": "Items" + }, + "assignedSeats": { + "message": "Assigned seats" + }, + "assigned": { + "message": "Assigned" + }, + "used": { + "message": "Used" + }, + "remaining": { + "message": "Remaining" + }, + "unlinkOrganization": { + "message": "Unlink organization" + }, + "manageSeats": { + "message": "MANAGE SEATS" + }, + "manageSeatsDescription": { + "message": "Adjustments to seats will be reflected in the next billing cycle." + }, + "unassignedSeatsDescription": { + "message": "Unassigned subscription seats" + }, + "purchaseSeatDescription": { + "message": "Additional seats purchased" + }, + "assignedSeatCannotUpdate": { + "message": "Assigned Seats can not be updated. Please contact your organization owner for assistance." + }, + "subscriptionUpdateFailed": { + "message": "Subscription update failed" } } diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts index dc3dea3c9ddb..20e98ce0842f 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/clients/clients.component.ts @@ -1,5 +1,5 @@ import { Component, OnInit } from "@angular/core"; -import { ActivatedRoute } from "@angular/router"; +import { ActivatedRoute, Router } from "@angular/router"; import { firstValueFrom } from "rxjs"; import { first } from "rxjs/operators"; @@ -13,6 +13,8 @@ import { ProviderUserType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response"; import { PlanType } from "@bitwarden/common/billing/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -50,8 +52,14 @@ export class ClientsComponent implements OnInit { protected actionPromise: Promise; private pagedClientsCount = 0; + protected enableConsolidatedBilling$ = this.configService.getFeatureFlag$( + FeatureFlag.EnableConsolidatedBilling, + false, + ); + constructor( private route: ActivatedRoute, + private router: Router, private providerService: ProviderService, private apiService: ApiService, private searchService: SearchService, @@ -64,20 +72,29 @@ export class ClientsComponent implements OnInit { private organizationService: OrganizationService, private organizationApiService: OrganizationApiServiceAbstraction, private dialogService: DialogService, + private configService: ConfigService, ) {} async ngOnInit() { // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.parent.params.subscribe(async (params) => { - this.providerId = params.providerId; - await this.load(); + const enableConsolidatedBilling = await firstValueFrom(this.enableConsolidatedBilling$); + + if (enableConsolidatedBilling) { + await this.router.navigate(["../manage-client-organizations"], { relativeTo: this.route }); + } else { + // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe + this.route.parent.params.subscribe(async (params) => { + this.providerId = params.providerId; - /* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */ - this.route.queryParams.pipe(first()).subscribe(async (qParams) => { - this.searchText = qParams.search; + await this.load(); + + /* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */ + this.route.queryParams.pipe(first()).subscribe(async (qParams) => { + this.searchText = qParams.search; + }); }); - }); + } } async load() { diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html index 333ea66e26cb..fe7f051652a3 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers-layout.component.html @@ -4,7 +4,11 @@ - + + + {{ "manageSeats" | i18n }} + {{ clientName }} + +
+

+ {{ "manageSeatsDescription" | i18n }} +

+ + + {{ "assignedSeats" | i18n }} + + + + +

+ {{ unassignedSeats }} + {{ "unassignedSeatsDescription" | i18n }} +

+

+ {{ AdditionalSeatPurchased }} + {{ "purchaseSeatDescription" | i18n }} +

+
+
+ + + + + diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.ts new file mode 100644 index 000000000000..2c8d59edc343 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organization-subscription.component.ts @@ -0,0 +1,115 @@ +import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { Component, Inject, OnInit } from "@angular/core"; + +import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response"; +import { BillingApiServiceAbstraction as BillingApiService } from "@bitwarden/common/billing/abstractions/billilng-api.service.abstraction"; +import { ProviderSubscriptionUpdateRequest } from "@bitwarden/common/billing/models/request/provider-subscription-update.request"; +import { Plans } from "@bitwarden/common/billing/models/response/provider-subscription-response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { DialogService } from "@bitwarden/components"; + +type ManageClientOrganizationDialogParams = { + organization: ProviderOrganizationOrganizationDetailsResponse; +}; + +@Component({ + templateUrl: "manage-client-organization-subscription.component.html", +}) +// eslint-disable-next-line rxjs-angular/prefer-takeuntil +export class ManageClientOrganizationSubscriptionComponent implements OnInit { + loading = true; + providerOrganizationId: string; + providerId: string; + + clientName: string; + assignedSeats: number; + unassignedSeats: number; + planName: string; + AdditionalSeatPurchased: number; + remainingOpenSeats: number; + + constructor( + public dialogRef: DialogRef, + @Inject(DIALOG_DATA) protected data: ManageClientOrganizationDialogParams, + private billingApiService: BillingApiService, + private i18nService: I18nService, + private platformUtilsService: PlatformUtilsService, + ) { + this.providerOrganizationId = data.organization.id; + this.providerId = data.organization.providerId; + this.clientName = data.organization.organizationName; + this.assignedSeats = data.organization.seats; + this.planName = data.organization.plan; + } + + async ngOnInit() { + try { + const response = await this.billingApiService.getProviderClientSubscriptions(this.providerId); + this.AdditionalSeatPurchased = this.getPurchasedSeatsByPlan(this.planName, response.plans); + const seatMinimum = this.getProviderSeatMinimumByPlan(this.planName, response.plans); + const assignedByPlan = this.getAssignedByPlan(this.planName, response.plans); + this.remainingOpenSeats = seatMinimum - assignedByPlan; + this.unassignedSeats = Math.abs(this.remainingOpenSeats); + } catch (error) { + this.remainingOpenSeats = 0; + this.AdditionalSeatPurchased = 0; + } + this.loading = false; + } + + async updateSubscription(assignedSeats: number) { + this.loading = true; + if (!assignedSeats) { + this.platformUtilsService.showToast( + "error", + null, + this.i18nService.t("assignedSeatCannotUpdate"), + ); + return; + } + + const request = new ProviderSubscriptionUpdateRequest(); + request.assignedSeats = assignedSeats; + + await this.billingApiService.putProviderClientSubscriptions( + this.providerId, + this.providerOrganizationId, + request, + ); + this.platformUtilsService.showToast("success", null, this.i18nService.t("subscriptionUpdated")); + this.loading = false; + this.dialogRef.close(); + } + + getPurchasedSeatsByPlan(planName: string, plans: Plans[]): number { + const plan = plans.find((plan) => plan.planName === planName); + if (plan) { + return plan.purchasedSeats; + } else { + return 0; + } + } + + getAssignedByPlan(planName: string, plans: Plans[]): number { + const plan = plans.find((plan) => plan.planName === planName); + if (plan) { + return plan.assignedSeats; + } else { + return 0; + } + } + + getProviderSeatMinimumByPlan(planName: string, plans: Plans[]) { + const plan = plans.find((plan) => plan.planName === planName); + if (plan) { + return plan.seatMinimum; + } else { + return 0; + } + } + + static open(dialogService: DialogService, data: ManageClientOrganizationDialogParams) { + return dialogService.open(ManageClientOrganizationSubscriptionComponent, { data }); + } +} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html new file mode 100644 index 000000000000..dc303d338f9b --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html @@ -0,0 +1,90 @@ + + + + + {{ "addNewOrganization" | i18n }} + + + + + + {{ "loading" | i18n }} + + + +

{{ "noClientsInList" | i18n }}

+ + + + + {{ "client" | i18n }} + {{ "assigned" | i18n }} + {{ "used" | i18n }} + {{ "remaining" | i18n }} + {{ "billingPlan" | i18n }} + + + + + + + + + + + + + {{ client.seats }} + + + {{ client.userCount }} + + + {{ client.seats - client.userCount }} + + + {{ client.plan }} + + + + + + + + + + + + +
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts new file mode 100644 index 000000000000..79dd25e8912e --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.ts @@ -0,0 +1,160 @@ +import { SelectionModel } from "@angular/cdk/collections"; +import { Component, OnInit } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { firstValueFrom } from "rxjs"; +import { first } from "rxjs/operators"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { SearchService } from "@bitwarden/common/abstractions/search.service"; +import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; +import { ProviderUserType } from "@bitwarden/common/admin-console/enums"; +import { ProviderOrganizationOrganizationDetailsResponse } from "@bitwarden/common/admin-console/models/response/provider/provider-organization.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { DialogService, TableDataSource } from "@bitwarden/components"; + +import { WebProviderService } from "../../../admin-console/providers/services/web-provider.service"; + +import { ManageClientOrganizationSubscriptionComponent } from "./manage-client-organization-subscription.component"; + +@Component({ + templateUrl: "manage-client-organizations.component.html", +}) + +// eslint-disable-next-line rxjs-angular/prefer-takeuntil +export class ManageClientOrganizationsComponent implements OnInit { + providerId: string; + loading = true; + manageOrganizations = false; + + set searchText(search: string) { + this.selection.clear(); + this.dataSource.filter = search; + } + + clients: ProviderOrganizationOrganizationDetailsResponse[]; + pagedClients: ProviderOrganizationOrganizationDetailsResponse[]; + + protected didScroll = false; + protected pageSize = 100; + protected actionPromise: Promise; + private pagedClientsCount = 0; + selection = new SelectionModel(true, []); + protected dataSource = new TableDataSource(); + + constructor( + private route: ActivatedRoute, + private providerService: ProviderService, + private apiService: ApiService, + private searchService: SearchService, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, + private validationService: ValidationService, + private webProviderService: WebProviderService, + private dialogService: DialogService, + ) {} + + async ngOnInit() { + // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe + this.route.parent.params.subscribe(async (params) => { + this.providerId = params.providerId; + + await this.load(); + + /* eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe, rxjs/no-nested-subscribe */ + this.route.queryParams.pipe(first()).subscribe(async (qParams) => { + this.searchText = qParams.search; + }); + }); + } + + async load() { + const response = await this.apiService.getProviderClients(this.providerId); + this.clients = response.data != null && response.data.length > 0 ? response.data : []; + this.dataSource.data = this.clients; + this.manageOrganizations = + (await this.providerService.get(this.providerId)).type === ProviderUserType.ProviderAdmin; + + this.loading = false; + } + + isPaging() { + const searching = this.isSearching(); + if (searching && this.didScroll) { + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.resetPaging(); + } + return !searching && this.clients && this.clients.length > this.pageSize; + } + + isSearching() { + return this.searchService.isSearchable(this.searchText); + } + + async resetPaging() { + this.pagedClients = []; + this.loadMore(); + } + + loadMore() { + if (!this.clients || this.clients.length <= this.pageSize) { + return; + } + const pagedLength = this.pagedClients.length; + let pagedSize = this.pageSize; + if (pagedLength === 0 && this.pagedClientsCount > this.pageSize) { + pagedSize = this.pagedClientsCount; + } + if (this.clients.length > pagedLength) { + this.pagedClients = this.pagedClients.concat( + this.clients.slice(pagedLength, pagedLength + pagedSize), + ); + } + this.pagedClientsCount = this.pagedClients.length; + this.didScroll = this.pagedClients.length > this.pageSize; + } + + async manageSubscription(organization: ProviderOrganizationOrganizationDetailsResponse) { + if (organization == null) { + return; + } + + const dialogRef = ManageClientOrganizationSubscriptionComponent.open(this.dialogService, { + organization: organization, + }); + + await firstValueFrom(dialogRef.closed); + await this.load(); + } + + async remove(organization: ProviderOrganizationOrganizationDetailsResponse) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: organization.organizationName, + content: { key: "detachOrganizationConfirmation" }, + type: "warning", + }); + + if (!confirmed) { + return false; + } + + this.actionPromise = this.webProviderService.detachOrganization( + this.providerId, + organization.id, + ); + try { + await this.actionPromise; + this.platformUtilsService.showToast( + "success", + null, + this.i18nService.t("detachedOrganization", organization.organizationName), + ); + await this.load(); + } catch (e) { + this.validationService.showError(e); + } + this.actionPromise = null; + } +} diff --git a/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts b/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts index 3982fa917b68..1311976c4b15 100644 --- a/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/billilng-api.service.abstraction.ts @@ -1,5 +1,7 @@ import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request"; import { OrganizationBillingStatusResponse } from "../../billing/models/response/organization-billing-status.response"; +import { ProviderSubscriptionUpdateRequest } from "../models/request/provider-subscription-update.request"; +import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response"; export abstract class BillingApiServiceAbstraction { cancelOrganizationSubscription: ( @@ -8,4 +10,10 @@ export abstract class BillingApiServiceAbstraction { ) => Promise; cancelPremiumUserSubscription: (request: SubscriptionCancellationRequest) => Promise; getBillingStatus: (id: string) => Promise; + getProviderClientSubscriptions: (providerId: string) => Promise; + putProviderClientSubscriptions: ( + providerId: string, + organizationId: string, + request: ProviderSubscriptionUpdateRequest, + ) => Promise; } diff --git a/libs/common/src/billing/models/request/provider-subscription-update.request.ts b/libs/common/src/billing/models/request/provider-subscription-update.request.ts new file mode 100644 index 000000000000..f2bf4c7e971f --- /dev/null +++ b/libs/common/src/billing/models/request/provider-subscription-update.request.ts @@ -0,0 +1,3 @@ +export class ProviderSubscriptionUpdateRequest { + assignedSeats: number; +} diff --git a/libs/common/src/billing/models/response/provider-subscription-response.ts b/libs/common/src/billing/models/response/provider-subscription-response.ts new file mode 100644 index 000000000000..522c5187254d --- /dev/null +++ b/libs/common/src/billing/models/response/provider-subscription-response.ts @@ -0,0 +1,38 @@ +import { BaseResponse } from "../../../models/response/base.response"; + +export class ProviderSubscriptionResponse extends BaseResponse { + status: string; + currentPeriodEndDate: Date; + discountPercentage?: number | null; + plans: Plans[] = []; + + constructor(response: any) { + super(response); + this.status = this.getResponseProperty("status"); + this.currentPeriodEndDate = new Date(this.getResponseProperty("currentPeriodEndDate")); + this.discountPercentage = this.getResponseProperty("discountPercentage"); + const plans = this.getResponseProperty("plans"); + if (plans != null) { + this.plans = plans.map((i: any) => new Plans(i)); + } + } +} + +export class Plans extends BaseResponse { + planName: string; + seatMinimum: number; + assignedSeats: number; + purchasedSeats: number; + cost: number; + cadence: string; + + constructor(response: any) { + super(response); + this.planName = this.getResponseProperty("PlanName"); + this.seatMinimum = this.getResponseProperty("SeatMinimum"); + this.assignedSeats = this.getResponseProperty("AssignedSeats"); + this.purchasedSeats = this.getResponseProperty("PurchasedSeats"); + this.cost = this.getResponseProperty("Cost"); + this.cadence = this.getResponseProperty("Cadence"); + } +} diff --git a/libs/common/src/billing/services/billing-api.service.ts b/libs/common/src/billing/services/billing-api.service.ts index 3d0ff550ea62..48866ab90d19 100644 --- a/libs/common/src/billing/services/billing-api.service.ts +++ b/libs/common/src/billing/services/billing-api.service.ts @@ -2,6 +2,8 @@ import { ApiService } from "../../abstractions/api.service"; import { BillingApiServiceAbstraction } from "../../billing/abstractions/billilng-api.service.abstraction"; import { SubscriptionCancellationRequest } from "../../billing/models/request/subscription-cancellation.request"; import { OrganizationBillingStatusResponse } from "../../billing/models/response/organization-billing-status.response"; +import { ProviderSubscriptionUpdateRequest } from "../models/request/provider-subscription-update.request"; +import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response"; export class BillingApiService implements BillingApiServiceAbstraction { constructor(private apiService: ApiService) {} @@ -34,4 +36,29 @@ export class BillingApiService implements BillingApiServiceAbstraction { return new OrganizationBillingStatusResponse(r); } + + async getProviderClientSubscriptions(providerId: string): Promise { + const r = await this.apiService.send( + "GET", + "/providers/" + providerId + "/billing/subscription", + null, + true, + true, + ); + return new ProviderSubscriptionResponse(r); + } + + async putProviderClientSubscriptions( + providerId: string, + organizationId: string, + request: ProviderSubscriptionUpdateRequest, + ): Promise { + return await this.apiService.send( + "PUT", + "/providers/" + providerId + "/organizations/" + organizationId, + request, + true, + false, + ); + } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index ca5ccc17b532..9470db94474d 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -7,6 +7,7 @@ export enum FeatureFlag { KeyRotationImprovements = "key-rotation-improvements", FlexibleCollectionsMigration = "flexible-collections-migration", ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners", + EnableConsolidatedBilling = "enable-consolidated-billing", } // Replace this with a type safe lookup of the feature flag values in PM-2282