diff --git a/.storybook/main.ts b/.storybook/main.ts index 544beb48c7..c71a74c2a7 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -38,9 +38,7 @@ const config: StorybookConfig = { }, env: (config) => ({ ...config, - FLAGS: JSON.stringify({ - secretsManager: true, - }), + FLAGS: JSON.stringify({}), }), webpackFinal: async (config, { configType }) => { if (config.resolve) { diff --git a/apps/browser/package.json b/apps/browser/package.json index d06eadf58d..ee6d100572 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/browser", - "version": "2024.3.1", + "version": "2024.4.1", "scripts": { "build": "webpack", "build:mv3": "cross-env MANIFEST_VERSION=3 webpack", diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index 6000df04bb..1f7c5bbe98 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index f7479ccf18..2111ea6704 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Parol silindi" + }, + "unassignedItemsBanner": { + "message": "Bildiriş: Təyin edilməmiş təşkilat elementləri artıq Bütün Anbarlar görünüşündə görünməyəndir və yalnız Admin Konsolu vasitəsilə əlçatandır. Bu elementləri görünən etmək üçün Admin Konsolundan bir kolleksiyaya təyin edin." + }, + "unassignedItemsBannerSelfHost": { + "message": "Bildiriş: 2 May 2024-cü ildən etibarən təyin edilməmiş təşkilat elementləri artıq Bütün Anbarlar görünüşündə görünməyən və yalnız Admin Konsolu vasitəsilə əlçatan olacaq. Bu elementləri görünən etmək üçün Admin Konsolundan bir kolleksiyaya təyin edin." } } diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index 0b11a5e3e6..08cb351abb 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index 7ffecb5d1f..87dfc8d3be 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Секретният ключ е премахнат" + }, + "unassignedItemsBanner": { + "message": "Известие: неразпределените елементи на организацията вече не се виждат в изгледа с „Всички трезори“, а са достъпни само през Административната конзола. Добавете тези елементи към някоя колекция в Административната конзола, за да станат видими." + }, + "unassignedItemsBannerSelfHost": { + "message": "Известие: от 2 май 2024г. неразпределените елементи на организациите вече няма се виждат в изгледа с „Всички трезори“, а ще бъдат достъпни само през Административната конзола. Добавете тези елементи към някоя колекция в Административната конзола, за да станат видими." } } diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index 63cb122765..1bdaeef7c6 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index e0a0633dda..92a667afeb 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index 1572f54c80..fc67602b60 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -3001,9 +3001,15 @@ "description": "Notification message for when saving credentials has failed." }, "removePasskey": { - "message": "Remove passkey" + "message": "Suprimeix la clau de pas" }, "passkeyRemoved": { - "message": "Passkey removed" + "message": "Clau de pas suprimida" + }, + "unassignedItemsBanner": { + "message": "Nota: els elements de l'organització sense assignar ja no es veuran a la vista \"Totes les caixes fortes\" i només es veuran des de la consola d'administració. Assigneu-los-hi una col·lecció des de la consola per fer-los visibles." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index e818527b51..d989d25bf2 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Přístupový klíč byl odebrán" + }, + "unassignedItemsBanner": { + "message": "Upozornění: Nepřiřazené položky organizace již nejsou viditelné ve Vašem zobrazení všech trezorů a jsou nyní přístupné jen v konzoli správce. Přiřaďte tyto položky do kolekce z konzole pro správce, aby byly viditelné." + }, + "unassignedItemsBannerSelfHost": { + "message": "Upozornění: Dne 2. května 2024 již nebudou nepřiřazené položky organizace viditelné v zobrazení Všechny trezory a budou přístupné jen prostřednictvím konzoly správce. Přiřaďte tyto položky do kolekce z konzoly pro správce, aby byly viditelné." } } diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index 410c8dbe80..79178bc9d5 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index 4c686aa5ce..d808d97412 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Adgangsnøgle fjernet" + }, + "unassignedItemsBanner": { + "message": "Bemærk: Utildelte organisationsemner er ikke længere synlige i Alle Bokse-visningen og er kun tilgængelige via Adminkonsollen. Føj disse emner til en samling fra Adminkonsollen for at gøre dem synlige." + }, + "unassignedItemsBannerSelfHost": { + "message": "Bemærk: Pr. 2. maj 2024 vil utildelte organisationsemner ikke længere være synlige i Alle Bokse-visningen og vil kun være tilgængelige via Admin-konsollen. Tildel disse emner til en samling via Admin-konsollen for at gøre dem synlige." } } diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index 502f5a8833..deb92e992d 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -2706,7 +2706,7 @@ "message": "Für dein Konto ist die Duo Zwei-Faktor-Authentifizierung erforderlich." }, "popoutTheExtensionToCompleteLogin": { - "message": "Popout the extension to complete login." + "message": "Koppel die Erweiterung ab, um die Anmeldung abzuschließen." }, "popoutExtension": { "message": "Popout-Erweiterung" @@ -3001,9 +3001,15 @@ "description": "Notification message for when saving credentials has failed." }, "removePasskey": { - "message": "Passkey löschen" + "message": "Passkey entfernen" }, "passkeyRemoved": { - "message": "Passkey gelöscht" + "message": "Passkey entfernt" + }, + "unassignedItemsBanner": { + "message": "Hinweis: Nicht zugeordnete Organisationseinträge sind nicht mehr in der Ansicht aller Tresore sichtbar und nur über die Administrator-Konsole zugänglich. Weise diese Einträge einer Sammlung aus der Administrator-Konsole zu, um sie sichtbar zu machen." + }, + "unassignedItemsBannerSelfHost": { + "message": "Hinweis: Ab dem 2. Mai 2024 werden nicht zugewiesene Organisationseinträge nicht mehr in der Ansicht aller Tresore sichtbar sein und sind nur über die Administrator-Konsole zugänglich. Weise diese Elemente einer Sammlung aus der Administrator-Konsole zu, um sie sichtbar zu machen." } } diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index a698cf2ec6..36b14e447f 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index d802d27700..4108db3996 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index 284d05d7bc..1ac55feb42 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organisation items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organisation items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index 77d4b05427..cbe214f0b3 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index da9d26eb6d..ee5666f3cc 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index fc108fcab5..ea1758468e 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -3001,9 +3001,15 @@ "description": "Notification message for when saving credentials has failed." }, "removePasskey": { - "message": "Remove passkey" + "message": "Eemalda pääsuvõti" }, "passkeyRemoved": { - "message": "Passkey removed" + "message": "Pääsuvõti on eemaldatud" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index e574d8e2e7..529a1e8127 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -95,10 +95,10 @@ "message": "Auto-fill login" }, "autoFillCard": { - "message": "Auto-fill card" + "message": "Auto-bete txartela" }, "autoFillIdentity": { - "message": "Auto-fill identity" + "message": "Auto-bete nortasuna" }, "generatePasswordCopied": { "message": "Sortu pasahitza (kopiatuta)" @@ -110,19 +110,19 @@ "message": "Bat datozen saio-hasierarik gabe" }, "noCards": { - "message": "No cards" + "message": "Txartelik ez" }, "noIdentities": { - "message": "No identities" + "message": "Nortasunik ez" }, "addLoginMenu": { "message": "Add login" }, "addCardMenu": { - "message": "Add card" + "message": "Gehitu txartela" }, "addIdentityMenu": { - "message": "Add identity" + "message": "Gehitu nortasuna" }, "unlockVaultMenu": { "message": "Desblokeatu kutxa gotorra" @@ -223,10 +223,10 @@ "message": "Bitwarden Laguntza zentroa" }, "communityForums": { - "message": "Explore Bitwarden community forums" + "message": "Esploratu Bitwarden komunitatearen foroak" }, "contactSupport": { - "message": "Contact Bitwarden support" + "message": "Jarri harremanetan Bitwardeneko laguntza taldearekin" }, "sync": { "message": "Sinkronizatu" @@ -269,7 +269,7 @@ "message": "Luzera" }, "passwordMinLength": { - "message": "Minimum password length" + "message": "Pasahitzaren gutxieneko luzera" }, "uppercase": { "message": "Letra larria (A-Z)" @@ -1064,7 +1064,7 @@ "message": "Edit browser settings." }, "autofillOverlayVisibilityOff": { - "message": "Off", + "message": "Itzalita", "description": "Overlay setting select option for disabling autofill overlay" }, "autofillOverlayVisibilityOnFieldFocus": { @@ -1592,10 +1592,10 @@ "message": "Ezarri pasahitz nagusia" }, "currentMasterPass": { - "message": "Current master password" + "message": "Oraingo pasahitz nagusia" }, "newMasterPass": { - "message": "New master password" + "message": "Pasahitz nagusi berria" }, "confirmNewMasterPass": { "message": "Confirm new master password" @@ -2266,10 +2266,10 @@ "message": "Please make sure your vault is unlocked and the Fingerprint phrase matches on the other device." }, "resendNotification": { - "message": "Resend notification" + "message": "Berbidali jakinarazpena" }, "viewAllLoginOptions": { - "message": "View all log in options" + "message": "Ikusi erregistro guztiak ezarpenetan" }, "notificationSentDevice": { "message": "A notification has been sent to your device." @@ -2293,13 +2293,13 @@ "message": "Check known data breaches for this password" }, "important": { - "message": "Important:" + "message": "Garrantzitsua:" }, "masterPasswordHint": { "message": "Your master password cannot be recovered if you forget it!" }, "characterMinimum": { - "message": "$LENGTH$ character minimum", + "message": "$LENGTH$ karaktere gutxienez", "placeholders": { "length": { "content": "$1", @@ -2326,7 +2326,7 @@ "message": "Select an item from this screen, or explore other options in settings." }, "gotIt": { - "message": "Got it" + "message": "Ulertuta" }, "autofillSettings": { "message": "Auto-fill settings" @@ -2359,25 +2359,25 @@ "message": "Logging in on" }, "opensInANewWindow": { - "message": "Opens in a new window" + "message": "Leiho berri batean irekitzen da" }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, "rememberThisDevice": { - "message": "Remember this device" + "message": "Gogoratu gailu hau" }, "uncheckIfPublicDevice": { "message": "Uncheck if using a public device" }, "approveFromYourOtherDevice": { - "message": "Approve from your other device" + "message": "Onartu zure beste gailutik" }, "requestAdminApproval": { - "message": "Request admin approval" + "message": "Eskatu administratzailearen onarpena" }, "approveWithMasterPassword": { - "message": "Approve with master password" + "message": "Onartu pasahitz nagusiarekin" }, "ssoIdentifierRequired": { "message": "Organization SSO identifier is required." @@ -2390,31 +2390,31 @@ "message": "Access denied. You do not have permission to view this page." }, "general": { - "message": "General" + "message": "Orokorra" }, "display": { - "message": "Display" + "message": "Bistaratzea" }, "accountSuccessfullyCreated": { - "message": "Account successfully created!" + "message": "Kontua zuzen sortu da!" }, "adminApprovalRequested": { - "message": "Admin approval requested" + "message": "Administratzailearen onarpena eskatuta" }, "adminApprovalRequestSentToAdmins": { - "message": "Your request has been sent to your admin." + "message": "Zure eskaera zure administratzaileari bidali zaio." }, "youWillBeNotifiedOnceApproved": { - "message": "You will be notified once approved." + "message": "Jakinaraziko zaizu onartzen denean." }, "troubleLoggingIn": { - "message": "Trouble logging in?" + "message": "Arazoak saioa hasterakoan?" }, "loginApproved": { "message": "Login approved" }, "userEmailMissing": { - "message": "User email missing" + "message": "Erabiltzailearen emaila falta da" }, "deviceTrusted": { "message": "Device trusted" @@ -2540,19 +2540,19 @@ "description": "Notification button text for starting a fileless import." }, "importing": { - "message": "Importing...", + "message": "Inportatzen...", "description": "Notification message for when an import is in progress." }, "dataSuccessfullyImported": { - "message": "Data successfully imported!", + "message": "Datuak zuzen inportatu dira!", "description": "Notification message for when an import has completed successfully." }, "dataImportFailed": { - "message": "Error importing. Check console for details.", + "message": "Errorea gertatu da inportatzean. Begiratu xehetasunak kontsolan.", "description": "Notification message for when an import has failed." }, "importNetworkError": { - "message": "Network error encountered during import.", + "message": "Sareko errorea gertatu da inportatzerakoan.", "description": "Notification message for when an import has failed due to a network error." }, "aliasDomain": { @@ -2602,11 +2602,11 @@ "description": "Screen reader text for when a login item is focused where a partial username is displayed. SR will announce this phrase before reading the text of the partial username" }, "noItemsToShow": { - "message": "No items to show", + "message": "Ez dago elementurik erakusteko", "description": "Text to show in overlay if there are no matching items" }, "newItem": { - "message": "New item", + "message": "Elementu berria", "description": "Button text to display in overlay when there are no matching items" }, "addNewVaultItem": { @@ -2618,32 +2618,32 @@ "description": "Screen reader text for announcing when the overlay opens on the page" }, "turnOn": { - "message": "Turn on" + "message": "Piztu" }, "ignore": { - "message": "Ignore" + "message": "Ezikusi" }, "importData": { - "message": "Import data", + "message": "Inportatu datuak", "description": "Used for the header of the import dialog, the import button and within the file-password-prompt" }, "importError": { - "message": "Import error" + "message": "Errorea inportatzerakoan" }, "importErrorDesc": { - "message": "There was a problem with the data you tried to import. Please resolve the errors listed below in your source file and try again." + "message": "Inportatzen saiatu zaren datuekin arazo bat egon da. Mesedez, konpondu ondoren adierazten diren akatsak eta saiatu berriro." }, "resolveTheErrorsBelowAndTryAgain": { - "message": "Resolve the errors below and try again." + "message": "Konpondu beheko akatsak eta saiatu berriro." }, "description": { - "message": "Description" + "message": "Deskribapena" }, "importSuccess": { - "message": "Data successfully imported" + "message": "Datuak zuzen inportatu dira" }, "importSuccessNumberOfItems": { - "message": "A total of $AMOUNT$ items were imported.", + "message": "Guztira $AMOUNT$ elementu inportatu dira.", "placeholders": { "amount": { "content": "$1", @@ -2652,7 +2652,7 @@ } }, "tryAgain": { - "message": "Try again" + "message": "Saiatu berriro" }, "verificationRequiredForActionSetPinToContinue": { "message": "Verification required for this action. Set a PIN to continue." @@ -2661,10 +2661,10 @@ "message": "Set PIN" }, "verifyWithBiometrics": { - "message": "Verify with biometrics" + "message": "Egiaztatu biometria erabiliz" }, "awaitingConfirmation": { - "message": "Awaiting confirmation" + "message": "Baieztapenaren zain" }, "couldNotCompleteBiometrics": { "message": "Could not complete biometrics." @@ -2673,13 +2673,13 @@ "message": "Need a different method?" }, "useMasterPassword": { - "message": "Use master password" + "message": "Erabili pasahitz nagusia" }, "usePin": { - "message": "Use PIN" + "message": "Erabili PIN kodea" }, "useBiometrics": { - "message": "Use biometrics" + "message": "Erabili biometria" }, "enterVerificationCodeSentToEmail": { "message": "Enter the verification code that was sent to your email." @@ -2688,10 +2688,10 @@ "message": "Resend code" }, "total": { - "message": "Total" + "message": "Guztira" }, "importWarning": { - "message": "You are importing data to $ORGANIZATION$. Your data may be shared with members of this organization. Do you want to proceed?", + "message": "$ORGANIZATION$(e)ra datuak inportatzen ari zara. Zure datuak erakunde horretako kideekin parteka daitezke. Jarraitu nahi duzu?", "placeholders": { "organization": { "content": "$1", @@ -2810,7 +2810,7 @@ "message": "You do not have a matching login for this site." }, "confirm": { - "message": "Confirm" + "message": "Berretsi" }, "savePasskey": { "message": "Save passkey" @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index fc702246d5..669eb151f4 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 591f12421c..17aea532ba 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -11,7 +11,7 @@ "description": "Extension description" }, "loginOrCreateNewAccount": { - "message": "Käytä salattua holviasi kirjautumalla sisään tai tai luo uusi tili." + "message": "Käytä salattua holviasi kirjautumalla sisään tai luo uusi tili." }, "createAccount": { "message": "Luo tili" @@ -802,7 +802,7 @@ "message": "Lue lisää" }, "authenticatorKeyTotp": { - "message": "Todennusaavain (TOTP)" + "message": "Todennusavain (TOTP)" }, "verificationCodeTotp": { "message": "Todennuskoodi (TOTP)" @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Suojausavain poistettiin" + }, + "unassignedItemsBanner": { + "message": "Huomautus: Organisaatioiden kokoelmiin määrittämättömät kohteet eivät enää näy laitteiden \"Kaikki holvit\" -näkymissä, vaan ne ovat nähtävissä vain Hallintapaneelista. Määritä kohteet kokoelmiin Hallintapaneelista, jotta ne ovat jatkossakin käytettävissä kaikilta laitteilta." + }, + "unassignedItemsBannerSelfHost": { + "message": "Huomautus: 2.5.2024 alkaen kokoelmiin määrittämättömät organisaatioiden kohteet eivät enää näy laitteiden \"Kaikki holvit\" -näkymissä, vaan ne ovat nähtävissä vain Hallintapaneelista. Määritä kohteet kokoelmiin Hallintapaneelista, jotta ne ovat jatkossakin käytettävissä kaikilta laitteilta." } } diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index d1cd0687e8..42d5060e28 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index adfb462a66..6cced1cb0d 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Clé d'identification (passkey) retirée" + }, + "unassignedItemsBanner": { + "message": "Notice : les éléments d'organisation non assignés ne sont plus visibles dans la vue Tous les coffres et sont uniquement accessibles via la console d'administration. Assignez ces éléments à une collection à partir de la console d'administration pour les rendre visibles." + }, + "unassignedItemsBannerSelfHost": { + "message": "Remarque : au 2 mai 2024, les éléments d'organisation non assignés ne sont plus visibles dans votre vue Tous les coffres sur tous les appareils et sont uniquement accessibles via la Console d'administration. Assignez ces éléments à une collection à partir de la Console d'administration pour les rendre visibles." } } diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index 0f2cab77d8..023e03b834 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index f9c352a683..1e633f5eb9 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index 84bc3461ae..44f645bc47 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index c98aae3a3b..e74a72bc4f 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index c720d99c71..cadd72a475 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "A jelszó eltávolításra került." + }, + "unassignedItemsBanner": { + "message": "Megjegyzés: A nem hozzá nem rendelt szervezeti elemek már nem láthatók az Összes széf nézetben és csak az Adminisztrátori konzolon keresztül érhetők el. Rendeljük ezeket az elemeket egy gyűjteményhez az Adminisztrátor konzolból, hogy láthatóvá tegyük azokat." + }, + "unassignedItemsBannerSelfHost": { + "message": "Figyelmeztetés: 2024. május 2-án a nem hozzá rendelt szervezeti elemek többé nem lesznek láthatók az Összes széf nézetben a különböző eszközökön és csak a Felügyeleti konzolon keresztül lesznek elérhetők. Rendeljük ezeket az elemeket egy gyűjteményhez az Adminisztrátori konzolból, hogy láthatóvá tegyük azokat." } } diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index ecc11725e7..9907a7520c 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index fb87081121..65a5a1ad04 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey rimossa" + }, + "unassignedItemsBanner": { + "message": "Avviso: gli elementi dell'organizzazione non assegnati non sono più visibili nella visualizzazione Tutte le Cassaforti su tutti i dispositivi e sono ora accessibili solo tramite la Console di amministrazione. Assegna questi elementi ad una raccolta dalla Console di amministrazione per renderli visibili." + }, + "unassignedItemsBannerSelfHost": { + "message": "Avviso: dal 2 maggio 2024, gli elementi dell'organizzazione non assegnati non saranno più visibili nella visualizzazione Tutte le Cassaforti su tutti i dispositivi e saranno accessibili solo tramite la Console di amministrazione. Assegna questi elementi ad una raccolta dalla Console di amministrazione per renderli visibili." } } diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index a247ee29cb..05fb7fe5de 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "パスキーを削除しました" + }, + "unassignedItemsBanner": { + "message": "注意: 割り当てられていない組織項目は、すべての保管庫のビューでは表示されなくなり、管理コンソールからのみアクセスできます。 管理コンソールからコレクションにこれらのアイテムを割り当てると、表示するようにできます。" + }, + "unassignedItemsBannerSelfHost": { + "message": "お知らせ:2024年5月2日に、 割り当てられていない組織アイテムはデバイス間のすべての保管庫ビューに表示されなくなり、管理コンソールからのみアクセス可能になります。 管理コンソールからコレクションにこれらのアイテムを割り当てると、表示できるようになります。" } } diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index 2559f4a109..d67b88ba9c 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index 0f2cab77d8..023e03b834 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index 01997a462c..61cfadc762 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index a99dd11d2f..c71fbdf7a8 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index 9537241f0d..0fc146c250 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Pašalintas slaptaraktis" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index 492141ff59..efac417556 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Piekļuves atslēga noņemta" + }, + "unassignedItemsBanner": { + "message": "Jāņem vērā: nepiešķirti apvienības vienumi vairs nav redzami skatā \"Visas glabātavas\" un ir sasniedzami tikai no pārvaldības konsoles, kur šie vienumi jāpiešķir krājumam, lai padarītu tos redzamus." + }, + "unassignedItemsBannerSelfHost": { + "message": "Jāņem vērā: 2024. gada 2. maijā nepiešķirti apvienības vienumi vairs nebūs redzami skatā \"Visas glabātavas\" un būs sasniedzami tikai no pārvaldības konsoles, kur šie vienumi jāpiešķir krājumam, lai padarītu tos redzamus." } } diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index b87a8c8ee6..9b66e6f0d6 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index c3859f9764..f9f37b2511 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index 0f2cab77d8..023e03b834 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index 5256eba72d..82d847ff0f 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index 0f2cab77d8..023e03b834 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index e189f1774f..13d59c4546 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey verwijderd" + }, + "unassignedItemsBanner": { + "message": "Let op: Niet-toegewezen organisatie-items zijn niet langer zichtbaar in de weergave van alle kluisjes en zijn alleen toegankelijk via de Admin Console. Om deze items zichtbaar te maken, moet je ze toewijzen aan een collectie via de Admin Console." + }, + "unassignedItemsBannerSelfHost": { + "message": "Kennisgeving: Vanaf 2 mei 2024 zijn niet-toegewezen organisatie-items op geen enkel apparaat meer zichtbaar in de weergave van alle kluisjes en alleen toegankelijk via de Admin Console. Je kunt deze items in het Admin Console aan een collectie toewijzen om ze zichtbaar te maken." } } diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index 0f2cab77d8..023e03b834 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index 0f2cab77d8..023e03b834 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index 4fa5fcb859..e4b97ec956 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey został usunięty" + }, + "unassignedItemsBanner": { + "message": "Uwaga: Nieprzypisane elementy w organizacji nie są już widoczne w widoku Wszystkie sejfy i są dostępne tylko przez Konsolę Administracyjną. Przypisz te elementy do kolekcji z konsoli administracyjnej, aby były one widoczne." + }, + "unassignedItemsBannerSelfHost": { + "message": "Uwaga: 2 maja 2024 r. nieprzypisane elementy w organizacji nie będą już widoczne w widoku Wszystkie sejfy i będą dostępne tylko przez Konsolę Administracyjną. Przypisz te elementy do kolekcji z konsoli administracyjnej, aby były one widoczne." } } diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index a21308af6a..a4e0688e3d 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index bd42d39535..c35531e445 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Chave de acesso removida" + }, + "unassignedItemsBanner": { + "message": "Aviso: Os itens da organização não atribuídos já não são visíveis na vista Todos os cofres e só são acessíveis através da consola de administração. Atribua estes itens a uma coleção a partir da Consola de administração para os tornar visíveis." + }, + "unassignedItemsBannerSelfHost": { + "message": "Aviso: A 2 de maio de 2024, os itens da organização não atribuídos deixarão de ser visíveis na vista Todos os cofres e só estarão acessíveis através da Consola de administração. Atribua estes itens a uma coleção a partir da Consola de administração para os tornar visíveis." } } diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index 49ca701a6f..885d70ca93 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 229ab31816..69d9ca200f 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -688,10 +688,10 @@ "message": "Запрос на обновление пароля логина при обнаружении изменений на сайте. Применяется ко всем авторизованным аккаунтам." }, "enableUsePasskeys": { - "message": "Запрос на сохранение и использование ключей доступа" + "message": "Запрос на сохранение и использование passkey" }, "usePasskeysDesc": { - "message": "Запрос на сохранение новых ключей или в авторизация с ключами, хранящимися в вашем хранилище. Применяется ко всем авторизованным аккаунтам." + "message": "Запрос на сохранение новых passkey или в авторизация с passkey, хранящимися в вашем хранилище. Применяется ко всем авторизованным аккаунтам." }, "notificationChangeDesc": { "message": "Обновить этот пароль в Bitwarden?" @@ -2786,25 +2786,25 @@ "message": "Подтвердите пароль к файлу" }, "typePasskey": { - "message": "Ключ доступа" + "message": "Passkey" }, "passkeyNotCopied": { - "message": "Ключ доступа не будет скопирован" + "message": "Passkey не будет скопирован" }, "passkeyNotCopiedAlert": { - "message": "Ключ доступа не будет скопирован в клонированный элемент. Продолжить клонирование этого элемента?" + "message": "Passkey не будет скопирован в клонированный элемент. Продолжить клонирование этого элемента?" }, "passkeyFeatureIsNotImplementedForAccountsWithoutMasterPassword": { "message": "Необходима верификация со стороны инициирующего сайта. Для аккаунтов без мастер-пароля эта возможность пока не реализована." }, "logInWithPasskey": { - "message": "Войти с ключом доступа?" + "message": "Войти с passkey?" }, "passkeyAlreadyExists": { - "message": "Для данного приложения уже существует ключ доступа." + "message": "Для данного приложения уже существует passkey." }, "noPasskeysFoundForThisApplication": { - "message": "Для данного приложения ключей доступа не найдено." + "message": "Для данного приложения не найден passkey." }, "noMatchingPasskeyLogin": { "message": "У вас нет подходящего логина для этого сайта." @@ -2813,28 +2813,28 @@ "message": "Подтвердить" }, "savePasskey": { - "message": "Сохранить ключ доступа" + "message": "Сохранить passkey" }, "savePasskeyNewLogin": { - "message": "Сохранить ключ доступа как новый логин" + "message": "Сохранить passkey как новый логин" }, "choosePasskey": { - "message": "Выберите логин, для которого будет сохранен данный ключ доступа" + "message": "Выберите логин, для которого будет сохранен данный passkey" }, "passkeyItem": { - "message": "Ключ доступа элемента" + "message": "Элемент passkey" }, "overwritePasskey": { - "message": "Перезаписать ключ доступа?" + "message": "Перезаписать passkey?" }, "overwritePasskeyAlert": { - "message": "Этот элемент уже содержит ключ доступа. Вы уверены, что хотите перезаписать текущий ключ?" + "message": "Этот элемент уже содержит passkey. Вы уверены, что хотите перезаписать текущий passkey?" }, "featureNotSupported": { "message": "Функция пока не поддерживается" }, "yourPasskeyIsLocked": { - "message": "Для использования ключа доступа необходима аутентификация. Для продолжения работы подтвердите свою личность." + "message": "Для использования passkey необходима аутентификация. Для продолжения работы подтвердите свою личность." }, "multifactorAuthenticationCancelled": { "message": "Многофакторная аутентификация отменена" @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey удален" + }, + "unassignedItemsBanner": { + "message": "Обратите внимание: неприсвоенные элементы организации больше не видны в представлении \"Все хранилища\" и доступны только через консоль администратора. Назначьте эти элементы коллекции в консоли администратора, чтобы сделать их видимыми." + }, + "unassignedItemsBannerSelfHost": { + "message": "Уведомление: 2 мая 2024 года неприсвоенные элементы организации больше не будут видны в представлении \"Все хранилища\" и будут доступны только через консоль администратора. Назначьте эти элементы коллекции в консоли администратора, чтобы сделать их видимыми." } } diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index 9857b8ca97..fb026226bb 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index b6f984a04c..a7948d78f3 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Prístupový kľúč bol odstránený" + }, + "unassignedItemsBanner": { + "message": "Upozornenie: Nepriradené položky organizácie už nie sú viditeľné v zobrazení Všetky Trezory a sú prístupné len cez administrátorskú konzolu. Aby boli viditeľné, priraďte tieto položky do kolekcie z konzoly administrátora." + }, + "unassignedItemsBannerSelfHost": { + "message": "Upozornenie: 2. mája nepriradené položky organizácie už nebudú viditeľné v zobrazení Všetky Trezory a budú prístupné len cez administrátorskú konzolu. Aby boli viditeľné, priraďte tieto položky do kolekcie z konzoly administrátora." } } diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index a8547066e6..2fac491c9c 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index 67ef9eb856..6ec1b6181b 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Приступачни кључ је уклоњен" + }, + "unassignedItemsBanner": { + "message": "Напомена: Недодељене ставке организације више нису видљиве у приказу Сви сефови и доступне су само преко Админ конзоле. Доделите ове ставке колекцији са Админ конзолом да бисте их учинили видљивим." + }, + "unassignedItemsBannerSelfHost": { + "message": "Обавештење: 2. маја 2024. недодељене ставке организације више неће бити видљиве у приказу Сви сефови и биће доступне само преко Админ конзоле. Доделите ове ставке колекцији са Админ конзолом да бисте их учинили видљивим." } } diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index e37b914b28..d798b98ea0 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -2931,7 +2931,7 @@ "message": "active" }, "locked": { - "message": "locked" + "message": "låst" }, "unlocked": { "message": "unlocked" @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index 0f2cab77d8..023e03b834 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index 0ee21fb3ec..827ca72854 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index d9da7727cc..cd0e12e6b0 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index 64d3f62a78..4820860de2 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Ключ доступу вилучено" + }, + "unassignedItemsBanner": { + "message": "Увага: непризначені елементи організації більше не видимі у поданні \"Усі сховища\" і доступні лише в консолі адміністратора. Щоб зробити їх видимими, призначте ці елементи збірці в консолі адміністратора." + }, + "unassignedItemsBannerSelfHost": { + "message": "Сповіщення: 2 травня 2024 року, непризначені елементи організації більше не будуть видимі в поданні \"Усі сховища\", і будуть доступні лише через консоль адміністратора. Щоб зробити їх видимими, призначте ці елементи збірці в консолі адміністратора." } } diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index 7aa43a4491..234e60e756 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index 1e31baee60..519313df81 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "通行密钥已移除" + }, + "unassignedItemsBanner": { + "message": "注意:未分配的组织项目在「所有密码库」视图中不再可见,只能通过管理控制台访问。通过管理控制台将这些项目分配给集合以使其可见。" + }, + "unassignedItemsBannerSelfHost": { + "message": "注意:从 2024 年 5 月 2 日起,未分配的组织项目在「所有密码库」视图中将不再可见,只能通过管理控制台访问。通过管理控制台将这些项目分配给集合以使其可见。" } } diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index c47bf538b8..b6f1ff574a 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -3005,5 +3005,11 @@ }, "passkeyRemoved": { "message": "Passkey removed" + }, + "unassignedItemsBanner": { + "message": "Notice: Unassigned organization items are no longer visible in the All Vaults view and only accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." + }, + "unassignedItemsBannerSelfHost": { + "message": "Notice: On May 2, 2024, unassigned organization items will no longer be visible in the All Vaults view and will only be accessible via the Admin Console. Assign these items to a collection from the Admin Console to make them visible." } } diff --git a/apps/browser/src/auth/background/service-factories/auth-request-service.factory.ts b/apps/browser/src/auth/background/service-factories/auth-request-service.factory.ts index bd96a211ba..c18fd1a112 100644 --- a/apps/browser/src/auth/background/service-factories/auth-request-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/auth-request-service.factory.ts @@ -18,17 +18,25 @@ import { factory, } from "../../../platform/background/service-factories/factory-options"; import { - stateServiceFactory, - StateServiceInitOptions, -} from "../../../platform/background/service-factories/state-service.factory"; + stateProviderFactory, + StateProviderInitOptions, +} from "../../../platform/background/service-factories/state-provider.factory"; + +import { accountServiceFactory, AccountServiceInitOptions } from "./account-service.factory"; +import { + internalMasterPasswordServiceFactory, + MasterPasswordServiceInitOptions, +} from "./master-password-service.factory"; type AuthRequestServiceFactoryOptions = FactoryOptions; export type AuthRequestServiceInitOptions = AuthRequestServiceFactoryOptions & AppIdServiceInitOptions & + AccountServiceInitOptions & + MasterPasswordServiceInitOptions & CryptoServiceInitOptions & ApiServiceInitOptions & - StateServiceInitOptions; + StateProviderInitOptions; export function authRequestServiceFactory( cache: { authRequestService?: AuthRequestServiceAbstraction } & CachedServices, @@ -41,9 +49,11 @@ export function authRequestServiceFactory( async () => new AuthRequestService( await appIdServiceFactory(cache, opts), + await accountServiceFactory(cache, opts), + await internalMasterPasswordServiceFactory(cache, opts), await cryptoServiceFactory(cache, opts), await apiServiceFactory(cache, opts), - await stateServiceFactory(cache, opts), + await stateProviderFactory(cache, opts), ), ); } diff --git a/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts b/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts index 4a0dd07b32..c602acadae 100644 --- a/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/key-connector-service.factory.ts @@ -31,6 +31,11 @@ import { StateProviderInitOptions, } from "../../../platform/background/service-factories/state-provider.factory"; +import { accountServiceFactory, AccountServiceInitOptions } from "./account-service.factory"; +import { + internalMasterPasswordServiceFactory, + MasterPasswordServiceInitOptions, +} from "./master-password-service.factory"; import { TokenServiceInitOptions, tokenServiceFactory } from "./token-service.factory"; type KeyConnectorServiceFactoryOptions = FactoryOptions & { @@ -40,6 +45,8 @@ type KeyConnectorServiceFactoryOptions = FactoryOptions & { }; export type KeyConnectorServiceInitOptions = KeyConnectorServiceFactoryOptions & + AccountServiceInitOptions & + MasterPasswordServiceInitOptions & CryptoServiceInitOptions & ApiServiceInitOptions & TokenServiceInitOptions & @@ -58,6 +65,8 @@ export function keyConnectorServiceFactory( opts, async () => new KeyConnectorService( + await accountServiceFactory(cache, opts), + await internalMasterPasswordServiceFactory(cache, opts), await cryptoServiceFactory(cache, opts), await apiServiceFactory(cache, opts), await tokenServiceFactory(cache, opts), diff --git a/apps/browser/src/auth/background/service-factories/login-strategy-service.factory.ts b/apps/browser/src/auth/background/service-factories/login-strategy-service.factory.ts index 15b1910e45..32dedd29b8 100644 --- a/apps/browser/src/auth/background/service-factories/login-strategy-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/login-strategy-service.factory.ts @@ -63,6 +63,7 @@ import { PasswordStrengthServiceInitOptions, } from "../../../tools/background/service_factories/password-strength-service.factory"; +import { accountServiceFactory, AccountServiceInitOptions } from "./account-service.factory"; import { authRequestServiceFactory, AuthRequestServiceInitOptions, @@ -75,6 +76,10 @@ import { keyConnectorServiceFactory, KeyConnectorServiceInitOptions, } from "./key-connector-service.factory"; +import { + internalMasterPasswordServiceFactory, + MasterPasswordServiceInitOptions, +} from "./master-password-service.factory"; import { tokenServiceFactory, TokenServiceInitOptions } from "./token-service.factory"; import { twoFactorServiceFactory, TwoFactorServiceInitOptions } from "./two-factor-service.factory"; import { @@ -85,6 +90,8 @@ import { type LoginStrategyServiceFactoryOptions = FactoryOptions; export type LoginStrategyServiceInitOptions = LoginStrategyServiceFactoryOptions & + AccountServiceInitOptions & + MasterPasswordServiceInitOptions & CryptoServiceInitOptions & ApiServiceInitOptions & TokenServiceInitOptions & @@ -116,6 +123,8 @@ export function loginStrategyServiceFactory( opts, async () => new LoginStrategyService( + await accountServiceFactory(cache, opts), + await internalMasterPasswordServiceFactory(cache, opts), await cryptoServiceFactory(cache, opts), await apiServiceFactory(cache, opts), await tokenServiceFactory(cache, opts), diff --git a/apps/browser/src/auth/background/service-factories/master-password-service.factory.ts b/apps/browser/src/auth/background/service-factories/master-password-service.factory.ts new file mode 100644 index 0000000000..a2f9052a3f --- /dev/null +++ b/apps/browser/src/auth/background/service-factories/master-password-service.factory.ts @@ -0,0 +1,42 @@ +import { + InternalMasterPasswordServiceAbstraction, + MasterPasswordServiceAbstraction, +} from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; +import { MasterPasswordService } from "@bitwarden/common/auth/services/master-password/master-password.service"; + +import { + CachedServices, + factory, + FactoryOptions, +} from "../../../platform/background/service-factories/factory-options"; +import { + stateProviderFactory, + StateProviderInitOptions, +} from "../../../platform/background/service-factories/state-provider.factory"; + +type MasterPasswordServiceFactoryOptions = FactoryOptions; + +export type MasterPasswordServiceInitOptions = MasterPasswordServiceFactoryOptions & + StateProviderInitOptions; + +export function internalMasterPasswordServiceFactory( + cache: { masterPasswordService?: InternalMasterPasswordServiceAbstraction } & CachedServices, + opts: MasterPasswordServiceInitOptions, +): Promise { + return factory( + cache, + "masterPasswordService", + opts, + async () => new MasterPasswordService(await stateProviderFactory(cache, opts)), + ); +} + +export async function masterPasswordServiceFactory( + cache: { masterPasswordService?: InternalMasterPasswordServiceAbstraction } & CachedServices, + opts: MasterPasswordServiceInitOptions, +): Promise { + return (await internalMasterPasswordServiceFactory( + cache, + opts, + )) as MasterPasswordServiceAbstraction; +} diff --git a/apps/browser/src/auth/background/service-factories/user-verification-service.factory.ts b/apps/browser/src/auth/background/service-factories/user-verification-service.factory.ts index e8be9099ca..a8b67b21ca 100644 --- a/apps/browser/src/auth/background/service-factories/user-verification-service.factory.ts +++ b/apps/browser/src/auth/background/service-factories/user-verification-service.factory.ts @@ -31,6 +31,11 @@ import { stateServiceFactory, } from "../../../platform/background/service-factories/state-service.factory"; +import { accountServiceFactory, AccountServiceInitOptions } from "./account-service.factory"; +import { + internalMasterPasswordServiceFactory, + MasterPasswordServiceInitOptions, +} from "./master-password-service.factory"; import { PinCryptoServiceInitOptions, pinCryptoServiceFactory } from "./pin-crypto-service.factory"; import { userDecryptionOptionsServiceFactory, @@ -46,6 +51,8 @@ type UserVerificationServiceFactoryOptions = FactoryOptions; export type UserVerificationServiceInitOptions = UserVerificationServiceFactoryOptions & StateServiceInitOptions & CryptoServiceInitOptions & + AccountServiceInitOptions & + MasterPasswordServiceInitOptions & I18nServiceInitOptions & UserVerificationApiServiceInitOptions & UserDecryptionOptionsServiceInitOptions & @@ -66,6 +73,8 @@ export function userVerificationServiceFactory( new UserVerificationService( await stateServiceFactory(cache, opts), await cryptoServiceFactory(cache, opts), + await accountServiceFactory(cache, opts), + await internalMasterPasswordServiceFactory(cache, opts), await i18nServiceFactory(cache, opts), await userVerificationApiServiceFactory(cache, opts), await userDecryptionOptionsServiceFactory(cache, opts), diff --git a/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts b/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts index 77d9741056..9a0423fca3 100644 --- a/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts +++ b/apps/browser/src/auth/popup/account-switching/account-switcher.component.ts @@ -6,6 +6,7 @@ import { Subject, firstValueFrom, map, switchMap, takeUntil } from "rxjs"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -32,6 +33,7 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy { private location: Location, private router: Router, private vaultTimeoutSettingsService: VaultTimeoutSettingsService, + private authService: AuthService, ) {} get accountLimit() { @@ -42,13 +44,14 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy { return this.accountSwitcherService.SPECIAL_ADD_ACCOUNT_ID; } - get availableAccounts$() { - return this.accountSwitcherService.availableAccounts$; - } - - get currentAccount$() { - return this.accountService.activeAccount$; - } + readonly availableAccounts$ = this.accountSwitcherService.availableAccounts$; + readonly currentAccount$ = this.accountService.activeAccount$.pipe( + switchMap((a) => + a == null + ? null + : this.authService.activeAccountStatus$.pipe(map((s) => ({ ...a, status: s }))), + ), + ); async ngOnInit() { const availableVaultTimeoutActions = await firstValueFrom( diff --git a/apps/browser/src/auth/popup/account-switching/current-account.component.ts b/apps/browser/src/auth/popup/account-switching/current-account.component.ts index 1c7f93bf30..643c37b9aa 100644 --- a/apps/browser/src/auth/popup/account-switching/current-account.component.ts +++ b/apps/browser/src/auth/popup/account-switching/current-account.component.ts @@ -4,6 +4,7 @@ import { ActivatedRoute, Router } from "@angular/router"; import { Observable, combineLatest, switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { UserId } from "@bitwarden/common/types/guid"; @@ -29,12 +30,14 @@ export class CurrentAccountComponent { private router: Router, private location: Location, private route: ActivatedRoute, + private authService: AuthService, ) { this.currentAccount$ = combineLatest([ this.accountService.activeAccount$, this.avatarService.avatarColor$, + this.authService.activeAccountStatus$, ]).pipe( - switchMap(async ([account, avatarColor]) => { + switchMap(async ([account, avatarColor, accountStatus]) => { if (account == null) { return null; } @@ -42,7 +45,7 @@ export class CurrentAccountComponent { id: account.id, name: account.name || account.email, email: account.email, - status: account.status, + status: accountStatus, avatarColor, }; diff --git a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts index f02a8ee201..fe04bee20e 100644 --- a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts +++ b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.spec.ts @@ -1,7 +1,8 @@ import { matches, mock } from "jest-mock-extended"; -import { BehaviorSubject, firstValueFrom, of, timeout } from "rxjs"; +import { BehaviorSubject, ReplaySubject, firstValueFrom, of, timeout } from "rxjs"; import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -12,22 +13,29 @@ import { UserId } from "@bitwarden/common/types/guid"; import { AccountSwitcherService } from "./account-switcher.service"; describe("AccountSwitcherService", () => { - const accountsSubject = new BehaviorSubject>(null); - const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(null); + let accountsSubject: BehaviorSubject>; + let activeAccountSubject: BehaviorSubject<{ id: UserId } & AccountInfo>; + let authStatusSubject: ReplaySubject>; const accountService = mock(); const avatarService = mock(); const messagingService = mock(); const environmentService = mock(); const logService = mock(); + const authService = mock(); let accountSwitcherService: AccountSwitcherService; beforeEach(() => { jest.resetAllMocks(); + accountsSubject = new BehaviorSubject>(null); + activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(null); + authStatusSubject = new ReplaySubject>(1); + // Use subject to allow for easy updates accountService.accounts$ = accountsSubject; accountService.activeAccount$ = activeAccountSubject; + authService.authStatuses$ = authStatusSubject; accountSwitcherService = new AccountSwitcherService( accountService, @@ -35,48 +43,59 @@ describe("AccountSwitcherService", () => { messagingService, environmentService, logService, + authService, ); }); + afterEach(() => { + accountsSubject.complete(); + activeAccountSubject.complete(); + authStatusSubject.complete(); + }); + describe("availableAccounts$", () => { - it("should return all accounts and an add account option when accounts are less than 5", async () => { - const user1AccountInfo: AccountInfo = { + it("should return all logged in accounts and an add account option when accounts are less than 5", async () => { + const accountInfo: AccountInfo = { name: "Test User 1", email: "test1@email.com", - status: AuthenticationStatus.Unlocked, }; avatarService.getUserAvatarColor$.mockReturnValue(of("#cccccc")); - accountsSubject.next({ - "1": user1AccountInfo, - } as Record); - - activeAccountSubject.next(Object.assign(user1AccountInfo, { id: "1" as UserId })); + accountsSubject.next({ ["1" as UserId]: accountInfo, ["2" as UserId]: accountInfo }); + authStatusSubject.next({ + ["1" as UserId]: AuthenticationStatus.Unlocked, + ["2" as UserId]: AuthenticationStatus.Locked, + }); + activeAccountSubject.next(Object.assign(accountInfo, { id: "1" as UserId })); const accounts = await firstValueFrom( accountSwitcherService.availableAccounts$.pipe(timeout(20)), ); - expect(accounts).toHaveLength(2); + expect(accounts).toHaveLength(3); expect(accounts[0].id).toBe("1"); expect(accounts[0].isActive).toBeTruthy(); - - expect(accounts[1].id).toBe("addAccount"); + expect(accounts[1].id).toBe("2"); expect(accounts[1].isActive).toBeFalsy(); + + expect(accounts[2].id).toBe("addAccount"); + expect(accounts[2].isActive).toBeFalsy(); }); it.each([5, 6])( "should return only accounts if there are %i accounts", async (numberOfAccounts) => { const seedAccounts: Record = {}; + const seedStatuses: Record = {}; for (let i = 0; i < numberOfAccounts; i++) { seedAccounts[`${i}` as UserId] = { email: `test${i}@email.com`, name: "Test User ${i}", - status: AuthenticationStatus.Unlocked, }; + seedStatuses[`${i}` as UserId] = AuthenticationStatus.Unlocked; } avatarService.getUserAvatarColor$.mockReturnValue(of("#cccccc")); accountsSubject.next(seedAccounts); + authStatusSubject.next(seedStatuses); activeAccountSubject.next( Object.assign(seedAccounts["1" as UserId], { id: "1" as UserId }), ); @@ -89,6 +108,26 @@ describe("AccountSwitcherService", () => { }); }, ); + + it("excludes logged out accounts", async () => { + const user1AccountInfo: AccountInfo = { + name: "Test User 1", + email: "", + }; + accountsSubject.next({ ["1" as UserId]: user1AccountInfo }); + authStatusSubject.next({ ["1" as UserId]: AuthenticationStatus.LoggedOut }); + accountsSubject.next({ + "1": user1AccountInfo, + } as Record); + + const accounts = await firstValueFrom( + accountSwitcherService.availableAccounts$.pipe(timeout(20)), + ); + + // Add account only + expect(accounts).toHaveLength(1); + expect(accounts[0].id).toBe("addAccount"); + }); }); describe("selectAccount", () => { diff --git a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts index 32ebee7c75..a73ec3e1f6 100644 --- a/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts +++ b/apps/browser/src/auth/popup/account-switching/services/account-switcher.service.ts @@ -11,6 +11,7 @@ import { } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -48,25 +49,27 @@ export class AccountSwitcherService { private messagingService: MessagingService, private environmentService: EnvironmentService, private logService: LogService, + authService: AuthService, ) { this.availableAccounts$ = combineLatest([ - this.accountService.accounts$, + accountService.accounts$, + authService.authStatuses$, this.accountService.activeAccount$, ]).pipe( - switchMap(async ([accounts, activeAccount]) => { - const accountEntries = Object.entries(accounts).filter( - ([_, account]) => account.status !== AuthenticationStatus.LoggedOut, + switchMap(async ([accounts, accountStatuses, activeAccount]) => { + const loggedInIds = Object.keys(accounts).filter( + (id: UserId) => accountStatuses[id] !== AuthenticationStatus.LoggedOut, ); // Accounts shouldn't ever be more than ACCOUNT_LIMIT but just in case do a greater than - const hasMaxAccounts = accountEntries.length >= this.ACCOUNT_LIMIT; + const hasMaxAccounts = loggedInIds.length >= this.ACCOUNT_LIMIT; const options: AvailableAccount[] = await Promise.all( - accountEntries.map(async ([id, account]) => { + loggedInIds.map(async (id: UserId) => { return { - name: account.name ?? account.email, - email: account.email, + name: accounts[id].name ?? accounts[id].email, + email: accounts[id].email, id: id, server: (await this.environmentService.getEnvironment(id))?.getHostname(), - status: account.status, + status: accountStatuses[id], isActive: id === activeAccount?.id, avatarColor: await firstValueFrom( this.avatarService.getUserAvatarColor$(id as UserId), diff --git a/apps/browser/src/auth/popup/lock.component.ts b/apps/browser/src/auth/popup/lock.component.ts index f232eca45a..16c32337cf 100644 --- a/apps/browser/src/auth/popup/lock.component.ts +++ b/apps/browser/src/auth/popup/lock.component.ts @@ -12,6 +12,7 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti 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 { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -41,6 +42,7 @@ export class LockComponent extends BaseLockComponent { fido2PopoutSessionData$ = fido2PopoutSessionData$(); constructor( + masterPasswordService: InternalMasterPasswordServiceAbstraction, router: Router, i18nService: I18nService, platformUtilsService: PlatformUtilsService, @@ -66,6 +68,7 @@ export class LockComponent extends BaseLockComponent { accountService: AccountService, ) { super( + masterPasswordService, router, i18nService, platformUtilsService, diff --git a/apps/browser/src/auth/popup/set-password.component.ts b/apps/browser/src/auth/popup/set-password.component.ts index ea1cacc7ac..accde2e9a0 100644 --- a/apps/browser/src/auth/popup/set-password.component.ts +++ b/apps/browser/src/auth/popup/set-password.component.ts @@ -1,65 +1,9 @@ import { Component } from "@angular/core"; -import { ActivatedRoute, Router } from "@angular/router"; import { SetPasswordComponent as BaseSetPasswordComponent } from "@bitwarden/angular/auth/components/set-password.component"; -import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; -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 { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; -import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; -import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; -import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; -import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { DialogService } from "@bitwarden/components"; @Component({ selector: "app-set-password", templateUrl: "set-password.component.html", }) -export class SetPasswordComponent extends BaseSetPasswordComponent { - constructor( - apiService: ApiService, - i18nService: I18nService, - cryptoService: CryptoService, - messagingService: MessagingService, - stateService: StateService, - passwordGenerationService: PasswordGenerationServiceAbstraction, - platformUtilsService: PlatformUtilsService, - policyApiService: PolicyApiServiceAbstraction, - policyService: PolicyService, - router: Router, - syncService: SyncService, - route: ActivatedRoute, - organizationApiService: OrganizationApiServiceAbstraction, - organizationUserService: OrganizationUserService, - userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction, - ssoLoginService: SsoLoginServiceAbstraction, - dialogService: DialogService, - ) { - super( - i18nService, - cryptoService, - messagingService, - passwordGenerationService, - platformUtilsService, - policyApiService, - policyService, - router, - apiService, - syncService, - route, - stateService, - organizationApiService, - organizationUserService, - userDecryptionOptionsService, - ssoLoginService, - dialogService, - ); - } -} +export class SetPasswordComponent extends BaseSetPasswordComponent {} diff --git a/apps/browser/src/auth/popup/sso.component.ts b/apps/browser/src/auth/popup/sso.component.ts index 228c7401fd..14df0d1752 100644 --- a/apps/browser/src/auth/popup/sso.component.ts +++ b/apps/browser/src/auth/popup/sso.component.ts @@ -9,7 +9,9 @@ import { UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -45,7 +47,9 @@ export class SsoComponent extends BaseSsoComponent { logService: LogService, userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, configService: ConfigService, - protected authService: AuthService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, + accountService: AccountService, + private authService: AuthService, @Inject(WINDOW) private win: Window, ) { super( @@ -63,6 +67,8 @@ export class SsoComponent extends BaseSsoComponent { logService, userDecryptionOptionsService, configService, + masterPasswordService, + accountService, ); environmentService.environment$.pipe(takeUntilDestroyed()).subscribe((env) => { diff --git a/apps/browser/src/auth/popup/two-factor.component.ts b/apps/browser/src/auth/popup/two-factor.component.ts index 9bac336695..98363bc93c 100644 --- a/apps/browser/src/auth/popup/two-factor.component.ts +++ b/apps/browser/src/auth/popup/two-factor.component.ts @@ -11,6 +11,8 @@ import { UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; @@ -58,6 +60,8 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { configService: ConfigService, ssoLoginService: SsoLoginServiceAbstraction, private dialogService: DialogService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, + accountService: AccountService, @Inject(WINDOW) protected win: Window, private browserMessagingApi: ZonedMessageListenerService, ) { @@ -78,6 +82,8 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { userDecryptionOptionsService, ssoLoginService, configService, + masterPasswordService, + accountService, ); super.onSuccessfulLogin = async () => { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index fd15ea6e93..45f095aee9 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -17,7 +17,7 @@ import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service"; import { BrowserApi } from "../../platform/browser/browser-api"; -import { BrowserStateService } from "../../platform/services/browser-state.service"; +import { DefaultBrowserStateService } from "../../platform/services/default-browser-state.service"; import { NotificationQueueMessageType } from "../enums/notification-queue-message-type.enum"; import { FormData } from "../services/abstractions/autofill.service"; import AutofillService from "../services/autofill.service"; @@ -49,7 +49,7 @@ describe("NotificationBackground", () => { const authService = mock(); const policyService = mock(); const folderService = mock(); - const stateService = mock(); + const stateService = mock(); const userNotificationSettingsService = mock(); const domainSettingsService = mock(); const environmentService = mock(); @@ -720,7 +720,7 @@ describe("NotificationBackground", () => { ); tabSendMessageSpy = jest.spyOn(BrowserApi, "tabSendMessage").mockImplementation(); editItemSpy = jest.spyOn(notificationBackground as any, "editItem"); - setAddEditCipherInfoSpy = jest.spyOn(stateService, "setAddEditCipherInfo"); + setAddEditCipherInfoSpy = jest.spyOn(cipherService, "setAddEditCipherInfo"); openAddEditVaultItemPopoutSpy = jest.spyOn( notificationBackground as any, "openAddEditVaultItemPopout", diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 74e6147505..9b65e4db0b 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -600,14 +600,14 @@ export default class NotificationBackground { } /** - * Sets the add/edit cipher info in the state service + * Sets the add/edit cipher info in the cipher service * and opens the add/edit vault item popout. * * @param cipherView - The cipher to edit * @param senderTab - The tab that the message was sent from */ private async editItem(cipherView: CipherView, senderTab: chrome.tabs.Tab) { - await this.stateService.setAddEditCipherInfo({ + await this.cipherService.setAddEditCipherInfo({ cipher: cipherView, collectionIds: cipherView.collectionIds, }); diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index c06df6603b..e65397a62b 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -33,7 +33,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherService } from "@bitwarden/common/vault/services/cipher.service"; import { BrowserApi } from "../../platform/browser/browser-api"; -import { BrowserStateService } from "../../platform/services/browser-state.service"; +import { DefaultBrowserStateService } from "../../platform/services/default-browser-state.service"; import { BrowserPlatformUtilsService } from "../../platform/services/platform-utils/browser-platform-utils.service"; import { AutofillService } from "../services/abstractions/autofill.service"; import { @@ -72,7 +72,7 @@ describe("OverlayBackground", () => { urls: { icons: "https://icons.bitwarden.com/" }, }), ); - const stateService = mock(); + const stateService = mock(); const autofillSettingsService = mock(); const i18nService = mock(); const platformUtilsService = mock(); @@ -592,7 +592,7 @@ describe("OverlayBackground", () => { beforeEach(() => { sender = mock({ tab: { id: 1 } }); jest - .spyOn(overlayBackground["stateService"], "setAddEditCipherInfo") + .spyOn(overlayBackground["cipherService"], "setAddEditCipherInfo") .mockImplementation(); jest.spyOn(overlayBackground as any, "openAddEditVaultItemPopout").mockImplementation(); }); @@ -600,7 +600,7 @@ describe("OverlayBackground", () => { it("will not open the add edit popout window if the message does not have a login cipher provided", () => { sendExtensionRuntimeMessage({ command: "autofillOverlayAddNewVaultItem" }, sender); - expect(overlayBackground["stateService"].setAddEditCipherInfo).not.toHaveBeenCalled(); + expect(overlayBackground["cipherService"].setAddEditCipherInfo).not.toHaveBeenCalled(); expect(overlayBackground["openAddEditVaultItemPopout"]).not.toHaveBeenCalled(); }); @@ -621,7 +621,7 @@ describe("OverlayBackground", () => { ); await flushPromises(); - expect(overlayBackground["stateService"].setAddEditCipherInfo).toHaveBeenCalled(); + expect(overlayBackground["cipherService"].setAddEditCipherInfo).toHaveBeenCalled(); expect(BrowserApi.sendMessage).toHaveBeenCalledWith( "inlineAutofillMenuRefreshAddEditCipher", ); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 50fb80ef1b..551263525e 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -636,7 +636,7 @@ class OverlayBackground implements OverlayBackgroundInterface { cipherView.type = CipherType.Login; cipherView.login = loginView; - await this.stateService.setAddEditCipherInfo({ + await this.cipherService.setAddEditCipherInfo({ cipher: cipherView, collectionIds: cipherView.collectionIds, }); diff --git a/apps/browser/src/autofill/utils/index.spec.ts b/apps/browser/src/autofill/utils/index.spec.ts index 2fe8496b8d..af67d41601 100644 --- a/apps/browser/src/autofill/utils/index.spec.ts +++ b/apps/browser/src/autofill/utils/index.spec.ts @@ -8,7 +8,6 @@ import { generateRandomCustomElementName, sendExtensionMessage, setElementStyles, - getFromLocalStorage, setupExtensionDisconnectAction, setupAutofillInitDisconnectAction, } from "./index"; @@ -124,33 +123,6 @@ describe("setElementStyles", () => { }); }); -describe("getFromLocalStorage", () => { - it("returns a promise with the storage object pulled from the extension storage api", async () => { - const localStorage: Record = { - testValue: "test", - another: "another", - }; - jest.spyOn(chrome.storage.local, "get").mockImplementation((keys, callback) => { - const localStorageObject: Record = {}; - - if (typeof keys === "string") { - localStorageObject[keys] = localStorage[keys]; - } else if (Array.isArray(keys)) { - for (const key of keys) { - localStorageObject[key] = localStorage[key]; - } - } - - callback(localStorageObject); - }); - - const returnValue = await getFromLocalStorage("testValue"); - - expect(chrome.storage.local.get).toHaveBeenCalled(); - expect(returnValue).toEqual({ testValue: "test" }); - }); -}); - describe("setupExtensionDisconnectAction", () => { afterEach(() => { jest.clearAllMocks(); diff --git a/apps/browser/src/autofill/utils/index.ts b/apps/browser/src/autofill/utils/index.ts index 2644425d70..72e7f9ab62 100644 --- a/apps/browser/src/autofill/utils/index.ts +++ b/apps/browser/src/autofill/utils/index.ts @@ -106,18 +106,6 @@ function setElementStyles( } } -/** - * Get data from local storage based on the keys provided. - * - * @param keys - String or array of strings of keys to get from local storage - * @deprecated Do not call this, use state-relevant services instead - */ -async function getFromLocalStorage(keys: string | string[]): Promise> { - return new Promise((resolve) => { - chrome.storage.local.get(keys, (storage: Record) => resolve(storage)); - }); -} - /** * Sets up a long-lived connection with the extension background * and triggers an onDisconnect event if the extension context @@ -278,7 +266,6 @@ export { buildSvgDomElement, sendExtensionMessage, setElementStyles, - getFromLocalStorage, setupExtensionDisconnectAction, setupAutofillInitDisconnectAction, elementIsFillableFormField, diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 9e6e66a41d..e0651fd381 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -10,6 +10,7 @@ import { AuthRequestServiceAbstraction, AuthRequestService, LoginEmailServiceAbstraction, + LoginEmailService, } from "@bitwarden/auth/common"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; @@ -32,6 +33,7 @@ import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abst import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/auth/abstractions/key-connector.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service"; @@ -46,6 +48,7 @@ import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation"; import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; import { KeyConnectorService } from "@bitwarden/common/auth/services/key-connector.service"; +import { MasterPasswordService } from "@bitwarden/common/auth/services/master-password/master-password.service"; import { SsoLoginService } from "@bitwarden/common/auth/services/sso-login.service"; import { TokenService } from "@bitwarden/common/auth/services/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service"; @@ -212,8 +215,8 @@ import BrowserLocalStorageService from "../platform/services/browser-local-stora 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"; import { BrowserTaskSchedulerService } from "../platform/services/browser-task-scheduler.service"; +import { DefaultBrowserStateService } from "../platform/services/default-browser-state.service"; import I18nService from "../platform/services/i18n.service"; import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service"; import { BackgroundPlatformUtilsService } from "../platform/services/platform-utils/background-platform-utils.service"; @@ -234,7 +237,7 @@ import RuntimeBackground from "./runtime.background"; export default class MainBackground { messagingService: MessagingServiceAbstraction; - storageService: AbstractStorageService & ObservableStorageService; + storageService: BrowserLocalStorageService; secureStorageService: AbstractStorageService; memoryStorageService: AbstractMemoryStorageService; memoryStorageForStateProviders: AbstractMemoryStorageService & ObservableStorageService; @@ -244,6 +247,7 @@ export default class MainBackground { keyGenerationService: KeyGenerationServiceAbstraction; cryptoService: CryptoServiceAbstraction; cryptoFunctionService: CryptoFunctionServiceAbstraction; + masterPasswordService: InternalMasterPasswordServiceAbstraction; tokenService: TokenServiceAbstraction; appIdService: AppIdServiceAbstraction; apiService: ApiServiceAbstraction; @@ -364,9 +368,10 @@ export default class MainBackground { const logoutCallback = async (expired: boolean, userId?: UserId) => await this.logout(expired, userId); - this.messagingService = this.popupOnlyContext - ? new BrowserMessagingPrivateModeBackgroundService() - : new BrowserMessagingService(); + this.messagingService = + this.isPrivateMode && BrowserApi.isManifestVersion(2) + ? new BrowserMessagingPrivateModeBackgroundService() + : new BrowserMessagingService(); this.logService = new ConsoleLogService(false); this.cryptoFunctionService = new WebCryptoFunctionService(self); this.keyGenerationService = new KeyGenerationService(this.cryptoFunctionService); @@ -408,13 +413,14 @@ export default class MainBackground { storageServiceProvider, ); - this.encryptService = flagEnabled("multithreadDecryption") - ? new MultithreadEncryptServiceImplementation( - this.cryptoFunctionService, - this.logService, - true, - ) - : new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, true); + this.encryptService = + flagEnabled("multithreadDecryption") && BrowserApi.isManifestVersion(2) + ? new MultithreadEncryptServiceImplementation( + this.cryptoFunctionService, + this.logService, + true, + ) + : new EncryptServiceImplementation(this.cryptoFunctionService, this.logService, true); this.singleUserStateProvider = new DefaultSingleUserStateProvider( storageServiceProvider, @@ -473,7 +479,7 @@ export default class MainBackground { new MigrationBuilderService(), ); - this.stateService = new BrowserStateService( + this.stateService = new DefaultBrowserStateService( this.storageService, this.secureStorageService, this.memoryStorageService, @@ -487,8 +493,11 @@ export default class MainBackground { const themeStateService = new DefaultThemeStateService(this.globalStateProvider); + this.masterPasswordService = new MasterPasswordService(this.stateProvider); + this.i18nService = new I18nService(BrowserApi.getUILanguage(), this.globalStateProvider); this.cryptoService = new BrowserCryptoService( + this.masterPasswordService, this.keyGenerationService, this.cryptoFunctionService, this.encryptService, @@ -515,7 +524,7 @@ export default class MainBackground { this.apiService, this.fileUploadService, ); - this.searchService = new SearchService(this.logService, this.i18nService); + this.searchService = new SearchService(this.logService, this.i18nService, this.stateProvider); this.collectionService = new CollectionService( this.cryptoService, @@ -532,6 +541,8 @@ export default class MainBackground { this.badgeSettingsService = new BadgeSettingsService(this.stateProvider); this.policyApiService = new PolicyApiService(this.policyService, this.apiService); this.keyConnectorService = new KeyConnectorService( + this.accountService, + this.masterPasswordService, this.cryptoService, this.apiService, this.tokenService, @@ -557,10 +568,13 @@ export default class MainBackground { const backgroundMessagingService = new (class extends MessagingServiceAbstraction { // AuthService should send the messages to the background not popup. send = (subscriber: string, arg: any = {}) => { + if (BrowserApi.isManifestVersion(3)) { + that.messagingService.send(subscriber, arg); + return; + } + const message = Object.assign({}, { command: subscriber }, arg); - // 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 - that.runtimeBackground.processMessage(message, that as any); + void that.runtimeBackground.processMessage(message, that as any); }; })(); @@ -585,9 +599,11 @@ export default class MainBackground { this.authRequestService = new AuthRequestService( this.appIdService, + this.accountService, + this.masterPasswordService, this.cryptoService, this.apiService, - this.stateService, + this.stateProvider, ); this.authService = new AuthService( @@ -603,7 +619,11 @@ export default class MainBackground { this.stateProvider, ); + this.loginEmailService = new LoginEmailService(this.stateProvider); + this.loginStrategyService = new LoginStrategyService( + this.accountService, + this.masterPasswordService, this.cryptoService, this.apiService, this.tokenService, @@ -651,12 +671,12 @@ export default class MainBackground { this.encryptService, this.cipherFileUploadService, this.configService, + this.stateProvider, ); this.folderService = new FolderService( this.cryptoService, this.i18nService, this.cipherService, - this.stateService, this.stateProvider, ); this.folderApiService = new FolderApiService(this.folderService, this.apiService); @@ -680,6 +700,8 @@ export default class MainBackground { this.userVerificationService = new UserVerificationService( this.stateService, this.cryptoService, + this.accountService, + this.masterPasswordService, this.i18nService, this.userVerificationApiService, this.userDecryptionOptionsService, @@ -702,6 +724,8 @@ export default class MainBackground { this.vaultSettingsService = new VaultSettingsService(this.stateProvider); this.vaultTimeoutService = new VaultTimeoutService( + this.accountService, + this.masterPasswordService, this.cipherService, this.folderService, this.collectionService, @@ -737,6 +761,8 @@ export default class MainBackground { this.providerService = new ProviderService(this.stateProvider); this.syncService = new SyncService( + this.masterPasswordService, + this.accountService, this.apiService, this.domainSettingsService, this.folderService, @@ -762,7 +788,7 @@ export default class MainBackground { this.apiService, this.stateProvider, this.logService, - this.accountService, + this.authService, this.taskSchedulerService, ); this.eventCollectionService = new EventCollectionService( @@ -770,7 +796,7 @@ export default class MainBackground { this.stateProvider, this.organizationService, this.eventUploadService, - this.accountService, + this.authService, ); this.totpService = new TotpService(this.cryptoFunctionService, this.logService); @@ -828,6 +854,7 @@ export default class MainBackground { logoutCallback, this.stateService, this.authService, + this.authRequestService, this.messagingService, this.taskSchedulerService, ); @@ -890,6 +917,8 @@ export default class MainBackground { this.fido2Service, ); this.nativeMessagingBackground = new NativeMessagingBackground( + this.accountService, + this.masterPasswordService, this.cryptoService, this.cryptoFunctionService, this.runtimeBackground, @@ -1123,7 +1152,7 @@ export default class MainBackground { const status = await this.authService.getAuthStatus(userId); const forcePasswordReset = - (await this.stateService.getForceSetPasswordReason({ userId: userId })) != + (await firstValueFrom(this.masterPasswordService.forceSetPasswordReason$(userId))) != ForceSetPasswordReason.None; await this.systemService.clearPendingClipboard(); @@ -1171,14 +1200,9 @@ export default class MainBackground { //Needs to be checked before state is cleaned const needStorageReseed = await this.needsStorageReseed(); - const currentUserId = await this.stateService.getUserId(); const newActiveUser = await this.stateService.clean({ userId: userId }); - if (userId == null || userId === currentUserId) { - this.searchService.clearIndex(); - } - - await this.stateEventRunnerService.handleEvent("logout", currentUserId as UserId); + await this.stateEventRunnerService.handleEvent("logout", userId); if (newActiveUser != null) { // we have a new active user, do not continue tearing down application @@ -1253,18 +1277,8 @@ export default class MainBackground { return; } - const getStorage = (): Promise => - new Promise((resolve) => { - chrome.storage.local.get(null, (o: any) => resolve(o)); - }); - - const clearStorage = (): Promise => - new Promise((resolve) => { - chrome.storage.local.clear(() => resolve()); - }); - - const storage = await getStorage(); - await clearStorage(); + const storage = await this.storageService.getAll(); + await this.storageService.clear(); for (const key in storage) { // eslint-disable-next-line diff --git a/apps/browser/src/background/nativeMessaging.background.ts b/apps/browser/src/background/nativeMessaging.background.ts index 240fb1dede..faf2e6e2cc 100644 --- a/apps/browser/src/background/nativeMessaging.background.ts +++ b/apps/browser/src/background/nativeMessaging.background.ts @@ -1,6 +1,8 @@ import { firstValueFrom } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; @@ -71,6 +73,8 @@ export class NativeMessagingBackground { private validatingFingerprint: boolean; constructor( + private accountService: AccountService, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, private cryptoService: CryptoService, private cryptoFunctionService: CryptoFunctionService, private runtimeBackground: RuntimeBackground, @@ -336,10 +340,14 @@ export class NativeMessagingBackground { ) as UserKey; await this.cryptoService.setUserKey(userKey); } else if (message.keyB64) { + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; // Backwards compatibility to support cases in which the user hasn't updated their desktop app // TODO: Remove after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3472) - let encUserKey = await this.stateService.getEncryptedCryptoSymmetricKey(); - encUserKey ||= await this.stateService.getMasterKeyEncryptedUserKey(); + const encUserKeyPrim = await this.stateService.getEncryptedCryptoSymmetricKey(); + const encUserKey = + encUserKeyPrim != null + ? new EncString(encUserKeyPrim) + : await this.masterPasswordService.getMasterKeyEncryptedUserKey(userId); if (!encUserKey) { throw new Error("No encrypted user key found"); } @@ -348,9 +356,9 @@ export class NativeMessagingBackground { ) as MasterKey; const userKey = await this.cryptoService.decryptUserKeyWithMasterKey( masterKey, - new EncString(encUserKey), + encUserKey, ); - await this.cryptoService.setMasterKey(masterKey); + await this.masterPasswordService.setMasterKey(masterKey, userId); await this.cryptoService.setUserKey(userKey); } else { throw new Error("No key received"); diff --git a/apps/browser/src/background/service-factories/event-collection-service.factory.ts b/apps/browser/src/background/service-factories/event-collection-service.factory.ts index ec892c73dd..b8f89c90bd 100644 --- a/apps/browser/src/background/service-factories/event-collection-service.factory.ts +++ b/apps/browser/src/background/service-factories/event-collection-service.factory.ts @@ -5,7 +5,10 @@ import { organizationServiceFactory, OrganizationServiceInitOptions, } from "../../admin-console/background/service-factories/organization-service.factory"; -import { accountServiceFactory } from "../../auth/background/service-factories/account-service.factory"; +import { + authServiceFactory, + AuthServiceInitOptions, +} from "../../auth/background/service-factories/auth-service.factory"; import { FactoryOptions, CachedServices, @@ -29,7 +32,8 @@ export type EventCollectionServiceInitOptions = EventCollectionServiceOptions & CipherServiceInitOptions & StateServiceInitOptions & OrganizationServiceInitOptions & - EventUploadServiceInitOptions; + EventUploadServiceInitOptions & + AuthServiceInitOptions; export function eventCollectionServiceFactory( cache: { eventCollectionService?: AbstractEventCollectionService } & CachedServices, @@ -45,7 +49,7 @@ export function eventCollectionServiceFactory( await stateProviderFactory(cache, opts), await organizationServiceFactory(cache, opts), await eventUploadServiceFactory(cache, opts), - await accountServiceFactory(cache, opts), + await authServiceFactory(cache, opts), ), ); } diff --git a/apps/browser/src/background/service-factories/event-upload-service.factory.ts b/apps/browser/src/background/service-factories/event-upload-service.factory.ts index 66d7dd667f..ae2f769063 100644 --- a/apps/browser/src/background/service-factories/event-upload-service.factory.ts +++ b/apps/browser/src/background/service-factories/event-upload-service.factory.ts @@ -1,7 +1,10 @@ import { EventUploadService as AbstractEventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; -import { accountServiceFactory } from "../../auth/background/service-factories/account-service.factory"; +import { + AuthServiceInitOptions, + authServiceFactory, +} from "../../auth/background/service-factories/auth-service.factory"; import { ApiServiceInitOptions, apiServiceFactory, @@ -28,6 +31,7 @@ export type EventUploadServiceInitOptions = EventUploadServiceOptions & ApiServiceInitOptions & StateServiceInitOptions & LogServiceInitOptions & + AuthServiceInitOptions & TaskSchedulerServiceInitOptions; export function eventUploadServiceFactory( @@ -43,7 +47,7 @@ export function eventUploadServiceFactory( await apiServiceFactory(cache, opts), await stateProviderFactory(cache, opts), await logServiceFactory(cache, opts), - await accountServiceFactory(cache, opts), + await authServiceFactory(cache, opts), await taskSchedulerServiceFactory(cache, opts), ), ); diff --git a/apps/browser/src/background/service-factories/search-service.factory.ts b/apps/browser/src/background/service-factories/search-service.factory.ts index 38c7620b5a..aa83d2afd2 100644 --- a/apps/browser/src/background/service-factories/search-service.factory.ts +++ b/apps/browser/src/background/service-factories/search-service.factory.ts @@ -14,12 +14,17 @@ import { logServiceFactory, LogServiceInitOptions, } from "../../platform/background/service-factories/log-service.factory"; +import { + stateProviderFactory, + StateProviderInitOptions, +} from "../../platform/background/service-factories/state-provider.factory"; type SearchServiceFactoryOptions = FactoryOptions; export type SearchServiceInitOptions = SearchServiceFactoryOptions & LogServiceInitOptions & - I18nServiceInitOptions; + I18nServiceInitOptions & + StateProviderInitOptions; export function searchServiceFactory( cache: { searchService?: AbstractSearchService } & CachedServices, @@ -33,6 +38,7 @@ export function searchServiceFactory( new SearchService( await logServiceFactory(cache, opts), await i18nServiceFactory(cache, opts), + await stateProviderFactory(cache, opts), ), ); } diff --git a/apps/browser/src/background/service-factories/vault-timeout-service.factory.ts b/apps/browser/src/background/service-factories/vault-timeout-service.factory.ts index 0e4d1420da..14f055114b 100644 --- a/apps/browser/src/background/service-factories/vault-timeout-service.factory.ts +++ b/apps/browser/src/background/service-factories/vault-timeout-service.factory.ts @@ -1,9 +1,17 @@ import { VaultTimeoutService as AbstractVaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service"; +import { + accountServiceFactory, + AccountServiceInitOptions, +} from "../../auth/background/service-factories/account-service.factory"; import { authServiceFactory, AuthServiceInitOptions, } from "../../auth/background/service-factories/auth-service.factory"; +import { + internalMasterPasswordServiceFactory, + MasterPasswordServiceInitOptions, +} from "../../auth/background/service-factories/master-password-service.factory"; import { CryptoServiceInitOptions, cryptoServiceFactory, @@ -57,6 +65,8 @@ type VaultTimeoutServiceFactoryOptions = FactoryOptions & { }; export type VaultTimeoutServiceInitOptions = VaultTimeoutServiceFactoryOptions & + AccountServiceInitOptions & + MasterPasswordServiceInitOptions & CipherServiceInitOptions & FolderServiceInitOptions & CollectionServiceInitOptions & @@ -79,6 +89,8 @@ export function vaultTimeoutServiceFactory( opts, async () => new VaultTimeoutService( + await accountServiceFactory(cache, opts), + await internalMasterPasswordServiceFactory(cache, opts), await cipherServiceFactory(cache, opts), await folderServiceFactory(cache, opts), await collectionServiceFactory(cache, opts), diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index c2aaf90fba..e07908950b 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "__MSG_extName__", "short_name": "__MSG_appName__", - "version": "2024.3.1", + "version": "2024.4.1", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index e7b0c0cd1e..d67b4affab 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -3,7 +3,7 @@ "minimum_chrome_version": "102.0", "name": "__MSG_extName__", "short_name": "__MSG_appName__", - "version": "2024.3.1", + "version": "2024.4.1", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/platform/background/service-factories/crypto-service.factory.ts b/apps/browser/src/platform/background/service-factories/crypto-service.factory.ts index 97614660d1..ed4fde162c 100644 --- a/apps/browser/src/platform/background/service-factories/crypto-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/crypto-service.factory.ts @@ -4,6 +4,10 @@ import { AccountServiceInitOptions, accountServiceFactory, } from "../../../auth/background/service-factories/account-service.factory"; +import { + internalMasterPasswordServiceFactory, + MasterPasswordServiceInitOptions, +} from "../../../auth/background/service-factories/master-password-service.factory"; import { StateServiceInitOptions, stateServiceFactory, @@ -34,6 +38,7 @@ import { StateProviderInitOptions, stateProviderFactory } from "./state-provider type CryptoServiceFactoryOptions = FactoryOptions; export type CryptoServiceInitOptions = CryptoServiceFactoryOptions & + MasterPasswordServiceInitOptions & KeyGenerationServiceInitOptions & CryptoFunctionServiceInitOptions & EncryptServiceInitOptions & @@ -53,6 +58,7 @@ export function cryptoServiceFactory( opts, async () => new BrowserCryptoService( + await internalMasterPasswordServiceFactory(cache, opts), await keyGenerationServiceFactory(cache, opts), await cryptoFunctionServiceFactory(cache, opts), await encryptServiceFactory(cache, opts), diff --git a/apps/browser/src/platform/background/service-factories/state-service.factory.ts b/apps/browser/src/platform/background/service-factories/state-service.factory.ts index 20a9ac074a..5567e00990 100644 --- a/apps/browser/src/platform/background/service-factories/state-service.factory.ts +++ b/apps/browser/src/platform/background/service-factories/state-service.factory.ts @@ -10,7 +10,7 @@ import { TokenServiceInitOptions, } from "../../../auth/background/service-factories/token-service.factory"; import { Account } from "../../../models/account"; -import { BrowserStateService } from "../../services/browser-state.service"; +import { DefaultBrowserStateService } from "../../services/default-browser-state.service"; import { environmentServiceFactory, @@ -46,15 +46,15 @@ export type StateServiceInitOptions = StateServiceFactoryOptions & MigrationRunnerInitOptions; export async function stateServiceFactory( - cache: { stateService?: BrowserStateService } & CachedServices, + cache: { stateService?: DefaultBrowserStateService } & CachedServices, opts: StateServiceInitOptions, -): Promise { +): Promise { const service = await factory( cache, "stateService", opts, async () => - new BrowserStateService( + new DefaultBrowserStateService( await diskStorageServiceFactory(cache, opts), await secureStorageServiceFactory(cache, opts), await memoryStorageServiceFactory(cache, opts), diff --git a/apps/browser/src/platform/decorators/session-sync-observable/browser-session.decorator.spec.ts b/apps/browser/src/platform/decorators/session-sync-observable/browser-session.decorator.spec.ts index 4b0226d54e..2092f6992b 100644 --- a/apps/browser/src/platform/decorators/session-sync-observable/browser-session.decorator.spec.ts +++ b/apps/browser/src/platform/decorators/session-sync-observable/browser-session.decorator.spec.ts @@ -3,7 +3,7 @@ import { BehaviorSubject } from "rxjs"; import { AbstractMemoryStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; -import { BrowserStateService } from "../../services/browser-state.service"; +import { DefaultBrowserStateService } from "../../services/default-browser-state.service"; import { browserSession } from "./browser-session.decorator"; import { SessionStorable } from "./session-storable"; @@ -25,7 +25,7 @@ describe("browserSession decorator", () => { }); it("should create if StateService is a constructor argument", () => { - const stateService = Object.create(BrowserStateService.prototype, { + const stateService = Object.create(DefaultBrowserStateService.prototype, { memoryStorageService: { value: Object.create(MemoryStorageService.prototype, { type: { value: MemoryStorageService.TYPE }, @@ -35,7 +35,7 @@ describe("browserSession decorator", () => { @browserSession class TestClass { - constructor(private stateService: BrowserStateService) {} + constructor(private stateService: DefaultBrowserStateService) {} } expect(new TestClass(stateService)).toBeDefined(); diff --git a/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts b/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts index 692e33bcce..6561d5074c 100644 --- a/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts +++ b/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts @@ -93,6 +93,10 @@ export class SessionSyncer { } async update(serializedValue: any) { + if (!serializedValue) { + return; + } + const unBuiltValue = JSON.parse(serializedValue); if (!BrowserApi.isManifestVersion(3) && BrowserApi.isBackgroundPage(self)) { await this.memoryStorageService.save(this.metaData.sessionKey, serializedValue); @@ -104,6 +108,10 @@ export class SessionSyncer { } private async updateSession(value: any) { + if (!value) { + return; + } + const serializedValue = JSON.stringify(value); if (BrowserApi.isManifestVersion(3) || BrowserApi.isBackgroundPage(self)) { await this.memoryStorageService.save(this.metaData.sessionKey, serializedValue); diff --git a/apps/browser/src/platform/flags.ts b/apps/browser/src/platform/flags.ts index 71a20edc5e..36aa698a7b 100644 --- a/apps/browser/src/platform/flags.ts +++ b/apps/browser/src/platform/flags.ts @@ -11,13 +11,13 @@ import { GroupPolicyEnvironment } from "../admin-console/types/group-policy-envi import { BrowserApi } from "./browser/browser-api"; // required to avoid linting errors when there are no flags -/* eslint-disable-next-line @typescript-eslint/ban-types */ +// eslint-disable-next-line @typescript-eslint/ban-types export type Flags = { accountSwitching?: boolean; } & SharedFlags; // required to avoid linting errors when there are no flags -/* eslint-disable-next-line @typescript-eslint/ban-types */ +// eslint-disable-next-line @typescript-eslint/ban-types export type DevFlags = { storeSessionDecrypted?: boolean; managedEnvironment?: GroupPolicyEnvironment; diff --git a/apps/browser/src/platform/popup/header.component.ts b/apps/browser/src/platform/popup/header.component.ts index 6b9e9c9a3e..ebda12c2a4 100644 --- a/apps/browser/src/platform/popup/header.component.ts +++ b/apps/browser/src/platform/popup/header.component.ts @@ -1,8 +1,10 @@ import { Component, Input } from "@angular/core"; -import { Observable, map } from "rxjs"; +import { Observable, combineLatest, map, of, switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { UserId } from "@bitwarden/common/types/guid"; import { enableAccountSwitching } from "../flags"; @@ -14,14 +16,18 @@ export class HeaderComponent { @Input() noTheme = false; @Input() hideAccountSwitcher = false; authedAccounts$: Observable; - constructor(accountService: AccountService) { + constructor(accountService: AccountService, authService: AuthService) { this.authedAccounts$ = accountService.accounts$.pipe( - map((accounts) => { + switchMap((accounts) => { if (!enableAccountSwitching()) { - return false; + return of(false); } - return Object.values(accounts).some((a) => a.status !== AuthenticationStatus.LoggedOut); + return combineLatest( + Object.keys(accounts).map((id) => authService.authStatusFor$(id as UserId)), + ).pipe( + map((statuses) => statuses.some((status) => status !== AuthenticationStatus.LoggedOut)), + ); }), ); } diff --git a/apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts b/apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts index 22ce8d4564..64935ab591 100644 --- a/apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts +++ b/apps/browser/src/platform/services/abstractions/abstract-chrome-storage-api.service.ts @@ -8,6 +8,25 @@ import { import { fromChromeEvent } from "../../browser/from-chrome-event"; +export const serializationIndicator = "__json__"; + +type serializedObject = { [serializationIndicator]: true; value: string }; + +export const objToStore = (obj: any) => { + if (obj == null) { + return null; + } + + if (obj instanceof Set) { + obj = Array.from(obj); + } + + return { + [serializationIndicator]: true, + value: JSON.stringify(obj), + }; +}; + export default abstract class AbstractChromeStorageService implements AbstractStorageService, ObservableStorageService { @@ -44,7 +63,7 @@ export default abstract class AbstractChromeStorageService return new Promise((resolve) => { this.chromeStorageApi.get(key, (obj: any) => { if (obj != null && obj[key] != null) { - resolve(obj[key] as T); + resolve(this.processGetObject(obj[key])); return; } resolve(null); @@ -57,14 +76,7 @@ export default abstract class AbstractChromeStorageService } async save(key: string, obj: any): Promise { - if (obj == null) { - // Fix safari not liking null in set - return this.remove(key); - } - - if (obj instanceof Set) { - obj = Array.from(obj); - } + obj = objToStore(obj); const keyedObj = { [key]: obj }; return new Promise((resolve) => { @@ -81,4 +93,22 @@ export default abstract class AbstractChromeStorageService }); }); } + + /** Backwards compatible resolution of retrieved object with new serialized storage */ + protected processGetObject(obj: T | serializedObject): T | null { + if (this.isSerialized(obj)) { + obj = JSON.parse(obj.value); + } + return obj as T; + } + + /** Type guard for whether an object is tagged as serialized */ + protected isSerialized(value: T | serializedObject): value is serializedObject { + const asSerialized = value as serializedObject; + return ( + asSerialized != null && + asSerialized[serializationIndicator] && + typeof asSerialized.value === "string" + ); + } } 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 82ec54975a..c8e2c502e7 100644 --- a/apps/browser/src/platform/services/abstractions/browser-state.service.ts +++ b/apps/browser/src/platform/services/abstractions/browser-state.service.ts @@ -1,19 +1,5 @@ import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; -import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; import { Account } from "../../../models/account"; -import { BrowserComponentState } from "../../../models/browserComponentState"; -import { BrowserSendComponentState } from "../../../models/browserSendComponentState"; -export abstract class BrowserStateService extends BaseStateServiceAbstraction { - getBrowserSendComponentState: (options?: StorageOptions) => Promise; - setBrowserSendComponentState: ( - value: BrowserSendComponentState, - options?: StorageOptions, - ) => Promise; - getBrowserSendTypeComponentState: (options?: StorageOptions) => Promise; - setBrowserSendTypeComponentState: ( - value: BrowserComponentState, - options?: StorageOptions, - ) => Promise; -} +export abstract class BrowserStateService extends BaseStateServiceAbstraction {} diff --git a/apps/browser/src/platform/services/abstractions/chrome-storage-api.service.spec.ts b/apps/browser/src/platform/services/abstractions/chrome-storage-api.service.spec.ts new file mode 100644 index 0000000000..812901879d --- /dev/null +++ b/apps/browser/src/platform/services/abstractions/chrome-storage-api.service.spec.ts @@ -0,0 +1,105 @@ +import AbstractChromeStorageService, { + objToStore, + serializationIndicator, +} from "./abstract-chrome-storage-api.service"; + +class TestChromeStorageApiService extends AbstractChromeStorageService {} + +describe("objectToStore", () => { + it("converts an object to a tagged string", () => { + const obj = { key: "value" }; + const result = objToStore(obj); + expect(result).toEqual({ + [serializationIndicator]: true, + value: JSON.stringify(obj), + }); + }); + + it("converts a set to an array prior to serialization", () => { + const obj = new Set(["value"]); + const result = objToStore(obj); + expect(result).toEqual({ + [serializationIndicator]: true, + value: JSON.stringify(Array.from(obj)), + }); + }); + + it("does nothing to null", () => { + expect(objToStore(null)).toEqual(null); + }); +}); + +describe("ChromeStorageApiService", () => { + let service: TestChromeStorageApiService; + let store: Record; + + beforeEach(() => { + store = {}; + + service = new TestChromeStorageApiService(chrome.storage.local); + }); + + describe("save", () => { + let setMock: jest.Mock; + + beforeEach(() => { + // setup save + setMock = chrome.storage.local.set as jest.Mock; + setMock.mockImplementation((data, callback) => { + Object.assign(store, data); + callback(); + }); + }); + + it("uses `objToStore` to prepare a value for set", async () => { + const key = "key"; + const value = { key: "value" }; + await service.save(key, value); + expect(setMock).toHaveBeenCalledWith( + { + [key]: objToStore(value), + }, + expect.any(Function), + ); + }); + }); + + describe("get", () => { + let getMock: jest.Mock; + const key = "key"; + + beforeEach(() => { + // setup get + getMock = chrome.storage.local.get as jest.Mock; + getMock.mockImplementation((key, callback) => { + callback({ [key]: store[key] }); + }); + }); + + it("returns a stored value when it is serialized", async () => { + const value = { key: "value" }; + store[key] = objToStore(value); + const result = await service.get(key); + expect(result).toEqual(value); + }); + + it("returns a stored value when it is not serialized", async () => { + const value = "value"; + store[key] = value; + const result = await service.get(key); + expect(result).toEqual(value); + }); + + it("returns null when the key does not exist", async () => { + const result = await service.get("key"); + expect(result).toBeNull(); + }); + + it("returns null when the stored object is null", async () => { + store[key] = null; + + const result = await service.get(key); + expect(result).toBeNull(); + }); + }); +}); diff --git a/apps/browser/src/platform/services/browser-crypto.service.ts b/apps/browser/src/platform/services/browser-crypto.service.ts index 969dbdf761..d7533a22d6 100644 --- a/apps/browser/src/platform/services/browser-crypto.service.ts +++ b/apps/browser/src/platform/services/browser-crypto.service.ts @@ -1,6 +1,7 @@ import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; @@ -17,6 +18,7 @@ import { UserKey } from "@bitwarden/common/types/key"; export class BrowserCryptoService extends CryptoService { constructor( + masterPasswordService: InternalMasterPasswordServiceAbstraction, keyGenerationService: KeyGenerationService, cryptoFunctionService: CryptoFunctionService, encryptService: EncryptService, @@ -28,6 +30,7 @@ export class BrowserCryptoService extends CryptoService { private biometricStateService: BiometricStateService, ) { super( + masterPasswordService, keyGenerationService, cryptoFunctionService, encryptService, diff --git a/apps/browser/src/platform/services/browser-local-storage.service.spec.ts b/apps/browser/src/platform/services/browser-local-storage.service.spec.ts new file mode 100644 index 0000000000..37ea37dbf6 --- /dev/null +++ b/apps/browser/src/platform/services/browser-local-storage.service.spec.ts @@ -0,0 +1,89 @@ +import { objToStore } from "./abstractions/abstract-chrome-storage-api.service"; +import BrowserLocalStorageService from "./browser-local-storage.service"; + +describe("BrowserLocalStorageService", () => { + let service: BrowserLocalStorageService; + let store: Record; + + beforeEach(() => { + store = {}; + + service = new BrowserLocalStorageService(); + }); + + describe("clear", () => { + let clearMock: jest.Mock; + + beforeEach(() => { + clearMock = chrome.storage.local.clear as jest.Mock; + }); + + it("uses the api to clear", async () => { + await service.clear(); + + expect(clearMock).toHaveBeenCalledTimes(1); + }); + }); + + describe("getAll", () => { + let getMock: jest.Mock; + + beforeEach(() => { + // setup get + getMock = chrome.storage.local.get as jest.Mock; + getMock.mockImplementation((key, callback) => { + if (key == null) { + callback(store); + } else { + callback({ [key]: store[key] }); + } + }); + }); + + it("returns all values", async () => { + store["key1"] = "string"; + store["key2"] = 0; + const result = await service.getAll(); + + expect(result).toEqual(store); + }); + + it("handles empty stores", async () => { + const result = await service.getAll(); + + expect(result).toEqual({}); + }); + + it("handles stores with null values", async () => { + store["key"] = null; + + const result = await service.getAll(); + expect(result).toEqual(store); + }); + + it("handles values processed for storage", async () => { + const obj = { test: 2 }; + const key = "key"; + store[key] = objToStore(obj); + + const result = await service.getAll(); + + expect(result).toEqual({ + [key]: obj, + }); + }); + + // This is a test of backwards compatibility before local storage was serialized. + it("handles values that were stored without processing for storage", async () => { + const obj = { test: 2 }; + const key = "key"; + store[key] = obj; + + const result = await service.getAll(); + + expect(result).toEqual({ + [key]: obj, + }); + }); + }); +}); diff --git a/apps/browser/src/platform/services/browser-local-storage.service.ts b/apps/browser/src/platform/services/browser-local-storage.service.ts index 2efd03a046..e1f9f63676 100644 --- a/apps/browser/src/platform/services/browser-local-storage.service.ts +++ b/apps/browser/src/platform/services/browser-local-storage.service.ts @@ -4,4 +4,32 @@ export default class BrowserLocalStorageService extends AbstractChromeStorageSer constructor() { super(chrome.storage.local); } + + /** + * Clears local storage + */ + async clear() { + await chrome.storage.local.clear(); + } + + /** + * Retrieves all objects stored in local storage. + * + * @remarks This method processes values prior to resolving, do not use `chrome.storage.local` directly + * @returns Promise resolving to keyed object of all stored data + */ + async getAll(): Promise> { + return new Promise((resolve) => { + this.chromeStorageApi.get(null, (allStorage) => { + const resolved = Object.entries(allStorage).reduce( + (agg, [key, value]) => { + agg[key] = this.processGetObject(value); + return agg; + }, + {} as Record, + ); + resolve(resolved); + }); + }); + } } 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 7e75b9b707..8f43998321 100644 --- a/apps/browser/src/platform/services/browser-state.service.spec.ts +++ b/apps/browser/src/platform/services/browser-state.service.spec.ts @@ -1,4 +1,5 @@ import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom } from "rxjs"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -12,15 +13,11 @@ import { GlobalState } from "@bitwarden/common/platform/models/domain/global-sta import { State } from "@bitwarden/common/platform/models/domain/state"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { mockAccountServiceWith } from "@bitwarden/common/spec"; -import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; -import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { UserId } from "@bitwarden/common/types/guid"; import { Account } from "../../models/account"; -import { BrowserComponentState } from "../../models/browserComponentState"; -import { BrowserSendComponentState } from "../../models/browserSendComponentState"; -import { BrowserStateService } from "./browser-state.service"; +import { DefaultBrowserStateService } from "./default-browser-state.service"; // disable session syncing to just test class jest.mock("../decorators/session-sync-observable/"); @@ -39,7 +36,7 @@ describe("Browser State Service", () => { const userId = "userId" as UserId; const accountService = mockAccountServiceWith(userId); - let sut: BrowserStateService; + let sut: DefaultBrowserStateService; beforeEach(() => { secureStorageService = mock(); @@ -71,7 +68,7 @@ describe("Browser State Service", () => { const stateGetter = (key: string) => Promise.resolve(state); memoryStorageService.get.mockImplementation(stateGetter); - sut = new BrowserStateService( + sut = new DefaultBrowserStateService( diskStorageService, secureStorageService, memoryStorageService, @@ -85,32 +82,17 @@ describe("Browser State Service", () => { ); }); - describe("getBrowserSendComponentState", () => { - it("should return a BrowserSendComponentState", async () => { - const sendState = new BrowserSendComponentState(); - sendState.sends = [new SendView(), new SendView()]; - sendState.typeCounts = new Map([ - [SendType.File, 3], - [SendType.Text, 5], - ]); - state.accounts[userId].send = sendState; - (global as any)["watch"] = state; + describe("add Account", () => { + it("should add account", async () => { + const newUserId = "newUserId" as UserId; + const newAcct = new Account({ + profile: { userId: newUserId }, + }); - const actual = await sut.getBrowserSendComponentState(); - expect(actual).toBeInstanceOf(BrowserSendComponentState); - expect(actual).toMatchObject(sendState); - }); - }); + await sut.addAccount(newAcct); - describe("getBrowserSendTypeComponentState", () => { - it("should return a BrowserComponentState", async () => { - const componentState = new BrowserComponentState(); - componentState.scrollY = 0; - componentState.searchText = "test"; - state.accounts[userId].sendType = componentState; - - const actual = await sut.getBrowserSendTypeComponentState(); - expect(actual).toStrictEqual(componentState); + const accts = await firstValueFrom(sut.accounts$); + expect(accts[newUserId]).toBeDefined(); }); }); }); diff --git a/apps/browser/src/platform/services/browser-state.service.ts b/apps/browser/src/platform/services/default-browser-state.service.ts similarity index 74% rename from apps/browser/src/platform/services/browser-state.service.ts rename to apps/browser/src/platform/services/default-browser-state.service.ts index ea410ee83a..f1f306dbc0 100644 --- a/apps/browser/src/platform/services/browser-state.service.ts +++ b/apps/browser/src/platform/services/default-browser-state.service.ts @@ -15,17 +15,15 @@ import { MigrationRunner } from "@bitwarden/common/platform/services/migration-r import { StateService as BaseStateService } from "@bitwarden/common/platform/services/state.service"; import { Account } from "../../models/account"; -import { BrowserComponentState } from "../../models/browserComponentState"; -import { BrowserSendComponentState } from "../../models/browserSendComponentState"; import { BrowserApi } from "../browser/browser-api"; import { browserSession, sessionSync } from "../decorators/session-sync-observable"; -import { BrowserStateService as StateServiceAbstraction } from "./abstractions/browser-state.service"; +import { BrowserStateService } from "./abstractions/browser-state.service"; @browserSession -export class BrowserStateService +export class DefaultBrowserStateService extends BaseStateService - implements StateServiceAbstraction + implements BrowserStateService { @sessionSync({ initializer: Account.fromJSON as any, // TODO: Remove this any when all any types are removed from Account @@ -115,46 +113,6 @@ export class BrowserStateService ); } - async getBrowserSendComponentState(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) - )?.send; - } - - async setBrowserSendComponentState( - value: BrowserSendComponentState, - options?: StorageOptions, - ): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - account.send = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - } - - async getBrowserSendTypeComponentState(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) - )?.sendType; - } - - async setBrowserSendTypeComponentState( - value: BrowserComponentState, - options?: StorageOptions, - ): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - account.sendType = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - } - // Overriding the base class to prevent deleting the cache on save. We register a storage listener // to delete the cache in the constructor above. protected override async saveAccountToDisk( diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index e0d898481b..c224e652f6 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -1,17 +1,20 @@ import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core"; import { NavigationEnd, Router, RouterOutlet } from "@angular/router"; import { ToastrService } from "ngx-toastr"; -import { filter, concatMap, Subject, takeUntil, firstValueFrom } from "rxjs"; +import { filter, concatMap, Subject, takeUntil, firstValueFrom, map } from "rxjs"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { DialogService, SimpleDialogOptions } from "@bitwarden/components"; 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 { BrowserSendStateService } from "../tools/popup/services/browser-send-state.service"; import { VaultBrowserStateService } from "../vault/services/vault-browser-state.service"; import { routerTransition } from "./app-routing.animations"; @@ -38,7 +41,9 @@ export class AppComponent implements OnInit, OnDestroy { private i18nService: I18nService, private router: Router, private stateService: BrowserStateService, + private browserSendStateService: BrowserSendStateService, private vaultBrowserStateService: VaultBrowserStateService, + private cipherService: CipherService, private changeDetectorRef: ChangeDetectorRef, private ngZone: NgZone, private platformUtilsService: ForegroundPlatformUtilsService, @@ -55,8 +60,9 @@ export class AppComponent implements OnInit, OnDestroy { this.activeUserId = userId; }); - this.stateService.activeAccountUnlocked$ + this.authService.activeAccountStatus$ .pipe( + map((status) => status === AuthenticationStatus.Unlocked), filter((unlocked) => unlocked), concatMap(async () => { await this.recordActivity(); @@ -157,7 +163,7 @@ export class AppComponent implements OnInit, OnDestroy { await this.clearComponentStates(); } if (url.startsWith("/tabs/")) { - await this.stateService.setAddEditCipherInfo(null); + await this.cipherService.setAddEditCipherInfo(null); } (window as any).previousPopupUrl = url; @@ -231,8 +237,8 @@ export class AppComponent implements OnInit, OnDestroy { await Promise.all([ this.vaultBrowserStateService.setBrowserGroupingsComponentState(null), this.vaultBrowserStateService.setBrowserVaultItemsComponentState(null), - this.stateService.setBrowserSendComponentState(null), - this.stateService.setBrowserSendTypeComponentState(null), + this.browserSendStateService.setBrowserSendComponentState(null), + this.browserSendStateService.setBrowserSendTypeComponentState(null), ]); } } diff --git a/apps/browser/src/popup/services/popup-search.service.ts b/apps/browser/src/popup/services/popup-search.service.ts index bc5e565e6c..40e6fd2d96 100644 --- a/apps/browser/src/popup/services/popup-search.service.ts +++ b/apps/browser/src/popup/services/popup-search.service.ts @@ -1,17 +1,14 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { StateProvider } from "@bitwarden/common/platform/state"; import { SearchService } from "@bitwarden/common/services/search.service"; export class PopupSearchService extends SearchService { - constructor( - private mainSearchService: SearchService, - logService: LogService, - i18nService: I18nService, - ) { - super(logService, i18nService); + constructor(logService: LogService, i18nService: I18nService, stateProvider: StateProvider) { + super(logService, i18nService, stateProvider); } - clearIndex() { + clearIndex(): Promise { throw new Error("Not available."); } @@ -19,7 +16,7 @@ export class PopupSearchService extends SearchService { throw new Error("Not available."); } - getIndexForSearch() { - return this.mainSearchService.getIndexForSearch(); + async getIndexForSearch() { + return await super.getIndexForSearch(); } } diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index c60d12357c..656d3ce292 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -62,6 +62,7 @@ import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/p import { AbstractMemoryStorageService, AbstractStorageService, + ObservableStorageService, } from "@bitwarden/common/platform/abstractions/storage.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; @@ -75,17 +76,15 @@ import { GlobalStateProvider, StateProvider, } from "@bitwarden/common/platform/state"; -import { SearchService } from "@bitwarden/common/services/search.service"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service"; -import { CipherFileUploadService } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service"; import { FolderService as FolderServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; +import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; +import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { DialogService } from "@bitwarden/components"; -import { VaultExportServiceAbstraction } from "@bitwarden/vault-export-core"; import { UnauthGuardService } from "../../auth/popup/services"; import { AutofillService as AutofillServiceAbstraction } from "../../autofill/services/abstractions/autofill.service"; @@ -100,12 +99,13 @@ import { BrowserEnvironmentService } from "../../platform/services/browser-envir import BrowserLocalStorageService from "../../platform/services/browser-local-storage.service"; import BrowserMessagingPrivateModePopupService from "../../platform/services/browser-messaging-private-mode-popup.service"; import BrowserMessagingService from "../../platform/services/browser-messaging.service"; -import { BrowserStateService } from "../../platform/services/browser-state.service"; import { BrowserTaskSchedulerService } from "../../platform/services/browser-task-scheduler.service"; +import { DefaultBrowserStateService } from "../../platform/services/default-browser-state.service"; import I18nService from "../../platform/services/i18n.service"; import { ForegroundPlatformUtilsService } from "../../platform/services/platform-utils/foreground-platform-utils.service"; import { ForegroundDerivedStateProvider } from "../../platform/state/foreground-derived-state.provider"; import { ForegroundMemoryStorageService } from "../../platform/storage/foreground-memory-storage.service"; +import { BrowserSendStateService } from "../../tools/popup/services/browser-send-state.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"; @@ -160,7 +160,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: MessagingService, useFactory: () => { - return needsBackgroundInit + return needsBackgroundInit && BrowserApi.isManifestVersion(2) ? new BrowserMessagingPrivateModePopupService() : new BrowserMessagingService(); }, @@ -188,19 +188,8 @@ const safeProviders: SafeProvider[] = [ }), safeProvider({ provide: SearchServiceAbstraction, - useFactory: (logService: LogService, i18nService: I18nServiceAbstraction) => { - return new PopupSearchService( - getBgService("searchService")(), - logService, - i18nService, - ); - }, - deps: [LogService, I18nServiceAbstraction], - }), - safeProvider({ - provide: CipherFileUploadService, - useFactory: getBgService("cipherFileUploadService"), - deps: [], + useClass: PopupSearchService, + deps: [LogService, I18nServiceAbstraction, StateProvider], }), safeProvider({ provide: CipherService, @@ -232,11 +221,6 @@ const safeProviders: SafeProvider[] = [ useClass: BrowserEnvironmentService, deps: [LogService, StateProvider, AccountServiceAbstraction], }), - safeProvider({ - provide: TotpService, - useFactory: getBgService("totpService"), - deps: [], - }), safeProvider({ provide: I18nServiceAbstraction, useFactory: (globalStateProvider: GlobalStateProvider) => { @@ -253,6 +237,11 @@ const safeProviders: SafeProvider[] = [ }, deps: [EncryptService], }), + safeProvider({ + provide: TotpServiceAbstraction, + useClass: TotpService, + deps: [CryptoFunctionService, LogService], + }), safeProvider({ provide: AuthRequestServiceAbstraction, useFactory: getBgService("authRequestService"), @@ -326,7 +315,7 @@ const safeProviders: SafeProvider[] = [ deps: [ CipherService, AutofillSettingsServiceAbstraction, - TotpService, + TotpServiceAbstraction, EventCollectionServiceAbstraction, LogService, DomainSettingsService, @@ -334,11 +323,6 @@ const safeProviders: SafeProvider[] = [ BillingAccountProfileStateService, ], }), - safeProvider({ - provide: VaultExportServiceAbstraction, - useFactory: getBgService("exportService"), - deps: [], - }), safeProvider({ provide: KeyConnectorService, useFactory: getBgService("keyConnectorService"), @@ -388,7 +372,15 @@ const safeProviders: SafeProvider[] = [ }), safeProvider({ provide: OBSERVABLE_MEMORY_STORAGE, - useClass: ForegroundMemoryStorageService, + useFactory: () => { + if (BrowserApi.isManifestVersion(2)) { + return new ForegroundMemoryStorageService(); + } + + return getBgService( + "memoryStorageForStateProviders", + )(); + }, deps: [], }), safeProvider({ @@ -414,7 +406,7 @@ const safeProviders: SafeProvider[] = [ tokenService: TokenServiceAbstraction, migrationRunner: MigrationRunner, ) => { - return new BrowserStateService( + return new DefaultBrowserStateService( storageService, secureStorageService, memoryStorageService, @@ -489,6 +481,11 @@ const safeProviders: SafeProvider[] = [ useClass: UserNotificationSettingsService, deps: [StateProvider], }), + safeProvider({ + provide: BrowserSendStateService, + useClass: BrowserSendStateService, + deps: [StateProvider], + }), safeProvider({ provide: TaskSchedulerService, useExisting: BrowserTaskSchedulerService, diff --git a/apps/browser/src/tools/popup/generator/generator.component.ts b/apps/browser/src/tools/popup/generator/generator.component.ts index c683b6f4a6..0c11c28f27 100644 --- a/apps/browser/src/tools/popup/generator/generator.component.ts +++ b/apps/browser/src/tools/popup/generator/generator.component.ts @@ -1,6 +1,7 @@ import { Location } from "@angular/common"; import { Component } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; +import { firstValueFrom } from "rxjs"; import { GeneratorComponent as BaseGeneratorComponent } from "@bitwarden/angular/tools/generator/components/generator.component"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -9,6 +10,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { AddEditCipherInfo } from "@bitwarden/common/vault/types/add-edit-cipher-info"; @@ -19,6 +21,7 @@ import { AddEditCipherInfo } from "@bitwarden/common/vault/types/add-edit-cipher export class GeneratorComponent extends BaseGeneratorComponent { private addEditCipherInfo: AddEditCipherInfo; private cipherState: CipherView; + private cipherService: CipherService; constructor( passwordGenerationService: PasswordGenerationServiceAbstraction, @@ -26,6 +29,7 @@ export class GeneratorComponent extends BaseGeneratorComponent { platformUtilsService: PlatformUtilsService, i18nService: I18nService, stateService: StateService, + cipherService: CipherService, route: ActivatedRoute, logService: LogService, private location: Location, @@ -40,10 +44,11 @@ export class GeneratorComponent extends BaseGeneratorComponent { route, window, ); + this.cipherService = cipherService; } async ngOnInit() { - this.addEditCipherInfo = await this.stateService.getAddEditCipherInfo(); + this.addEditCipherInfo = await firstValueFrom(this.cipherService.addEditCipherInfo$); if (this.addEditCipherInfo != null) { this.cipherState = this.addEditCipherInfo.cipher; } @@ -64,7 +69,7 @@ export class GeneratorComponent extends BaseGeneratorComponent { this.addEditCipherInfo.cipher = this.cipherState; // 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.stateService.setAddEditCipherInfo(this.addEditCipherInfo); + this.cipherService.setAddEditCipherInfo(this.addEditCipherInfo); this.close(); } diff --git a/apps/browser/src/tools/popup/send/send-groupings.component.ts b/apps/browser/src/tools/popup/send/send-groupings.component.ts index 25fa67d51a..a49773367d 100644 --- a/apps/browser/src/tools/popup/send/send-groupings.component.ts +++ b/apps/browser/src/tools/popup/send/send-groupings.component.ts @@ -18,7 +18,7 @@ import { DialogService } from "@bitwarden/components"; import { BrowserSendComponentState } from "../../../models/browserSendComponentState"; import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; -import { BrowserStateService } from "../../../platform/services/abstractions/browser-state.service"; +import { BrowserSendStateService } from "../services/browser-send-state.service"; const ComponentId = "SendComponent"; @@ -43,7 +43,7 @@ export class SendGroupingsComponent extends BaseSendComponent { ngZone: NgZone, policyService: PolicyService, searchService: SearchService, - private stateService: BrowserStateService, + private stateService: BrowserSendStateService, private router: Router, private syncService: SyncService, private changeDetectorRef: ChangeDetectorRef, @@ -171,9 +171,7 @@ export class SendGroupingsComponent extends BaseSendComponent { } showSearching() { - return ( - this.hasSearched || (!this.searchPending && this.searchService.isSearchable(this.searchText)) - ); + return this.hasSearched || (!this.searchPending && this.isSearchable); } private calculateTypeCounts() { diff --git a/apps/browser/src/tools/popup/send/send-type.component.ts b/apps/browser/src/tools/popup/send/send-type.component.ts index 4b27edc043..aca02587de 100644 --- a/apps/browser/src/tools/popup/send/send-type.component.ts +++ b/apps/browser/src/tools/popup/send/send-type.component.ts @@ -19,7 +19,7 @@ import { DialogService } from "@bitwarden/components"; import { BrowserComponentState } from "../../../models/browserComponentState"; import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; -import { BrowserStateService } from "../../../platform/services/abstractions/browser-state.service"; +import { BrowserSendStateService } from "../services/browser-send-state.service"; const ComponentId = "SendTypeComponent"; @@ -42,7 +42,7 @@ export class SendTypeComponent extends BaseSendComponent { ngZone: NgZone, policyService: PolicyService, searchService: SearchService, - private stateService: BrowserStateService, + private stateService: BrowserSendStateService, private route: ActivatedRoute, private location: Location, private changeDetectorRef: ChangeDetectorRef, diff --git a/apps/browser/src/tools/popup/services/browser-send-state.service.spec.ts b/apps/browser/src/tools/popup/services/browser-send-state.service.spec.ts new file mode 100644 index 0000000000..3dafc0934a --- /dev/null +++ b/apps/browser/src/tools/popup/services/browser-send-state.service.spec.ts @@ -0,0 +1,61 @@ +import { + FakeAccountService, + mockAccountServiceWith, +} from "@bitwarden/common/../spec/fake-account-service"; +import { FakeStateProvider } from "@bitwarden/common/../spec/fake-state-provider"; +import { awaitAsync } from "@bitwarden/common/../spec/utils"; + +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { BrowserComponentState } from "../../../models/browserComponentState"; +import { BrowserSendComponentState } from "../../../models/browserSendComponentState"; + +import { BrowserSendStateService } from "./browser-send-state.service"; + +describe("Browser Send State Service", () => { + let stateProvider: FakeStateProvider; + + let accountService: FakeAccountService; + let stateService: BrowserSendStateService; + const mockUserId = Utils.newGuid() as UserId; + + beforeEach(() => { + accountService = mockAccountServiceWith(mockUserId); + stateProvider = new FakeStateProvider(accountService); + + stateService = new BrowserSendStateService(stateProvider); + }); + + describe("getBrowserSendComponentState", () => { + it("should return BrowserSendComponentState", async () => { + const state = new BrowserSendComponentState(); + state.scrollY = 0; + state.searchText = "test"; + state.typeCounts = new Map().set(SendType.File, 1); + + await stateService.setBrowserSendComponentState(state); + + await awaitAsync(); + + const actual = await stateService.getBrowserSendComponentState(); + expect(actual).toStrictEqual(state); + }); + }); + + describe("getBrowserSendTypeComponentState", () => { + it("should return BrowserComponentState", async () => { + const state = new BrowserComponentState(); + state.scrollY = 0; + state.searchText = "test"; + + await stateService.setBrowserSendTypeComponentState(state); + + await awaitAsync(); + + const actual = await stateService.getBrowserSendTypeComponentState(); + expect(actual).toStrictEqual(state); + }); + }); +}); diff --git a/apps/browser/src/tools/popup/services/browser-send-state.service.ts b/apps/browser/src/tools/popup/services/browser-send-state.service.ts new file mode 100644 index 0000000000..b814ee5bc9 --- /dev/null +++ b/apps/browser/src/tools/popup/services/browser-send-state.service.ts @@ -0,0 +1,65 @@ +import { Observable, firstValueFrom } from "rxjs"; + +import { ActiveUserState, StateProvider } from "@bitwarden/common/platform/state"; + +import { BrowserComponentState } from "../../../models/browserComponentState"; +import { BrowserSendComponentState } from "../../../models/browserSendComponentState"; + +import { BROWSER_SEND_COMPONENT, BROWSER_SEND_TYPE_COMPONENT } from "./key-definitions"; + +/** Get or set the active user's component state for the Send browser component + */ +export class BrowserSendStateService { + /** Observable that contains the current state for active user Sends including the send data and type counts + * along with the search text and scroll position + */ + browserSendComponentState$: Observable; + + /** Observable that contains the current state for active user Sends that only includes the search text + * and scroll position + */ + browserSendTypeComponentState$: Observable; + + private activeUserBrowserSendComponentState: ActiveUserState; + private activeUserBrowserSendTypeComponentState: ActiveUserState; + + constructor(protected stateProvider: StateProvider) { + this.activeUserBrowserSendComponentState = this.stateProvider.getActive(BROWSER_SEND_COMPONENT); + this.browserSendComponentState$ = this.activeUserBrowserSendComponentState.state$; + + this.activeUserBrowserSendTypeComponentState = this.stateProvider.getActive( + BROWSER_SEND_TYPE_COMPONENT, + ); + this.browserSendTypeComponentState$ = this.activeUserBrowserSendTypeComponentState.state$; + } + + /** Get the active user's browser send component state + * @returns { BrowserSendComponentState } contains the sends and type counts along with the scroll position and search text for the + * send component on the browser + */ + async getBrowserSendComponentState(): Promise { + return await firstValueFrom(this.browserSendComponentState$); + } + + /** Set the active user's browser send component state + * @param { BrowserSendComponentState } value sets the sends and type counts along with the scroll position and search text for + * the send component on the browser + */ + async setBrowserSendComponentState(value: BrowserSendComponentState): Promise { + await this.activeUserBrowserSendComponentState.update(() => value); + } + + /** Get the active user's browser component state + * @returns { BrowserComponentState } contains the scroll position and search text for the sends menu on the browser + */ + async getBrowserSendTypeComponentState(): Promise { + return await firstValueFrom(this.browserSendTypeComponentState$); + } + + /** Set the active user's browser component state + * @param { BrowserComponentState } value set the scroll position and search text for the send component on the browser + */ + async setBrowserSendTypeComponentState(value: BrowserComponentState): Promise { + await this.activeUserBrowserSendTypeComponentState.update(() => value); + } +} diff --git a/apps/browser/src/tools/popup/services/key-definitions.spec.ts b/apps/browser/src/tools/popup/services/key-definitions.spec.ts new file mode 100644 index 0000000000..3ba574efa3 --- /dev/null +++ b/apps/browser/src/tools/popup/services/key-definitions.spec.ts @@ -0,0 +1,40 @@ +import { Jsonify } from "type-fest"; + +import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; + +import { BrowserSendComponentState } from "../../../models/browserSendComponentState"; + +import { BROWSER_SEND_COMPONENT, BROWSER_SEND_TYPE_COMPONENT } from "./key-definitions"; + +describe("Key definitions", () => { + describe("BROWSER_SEND_COMPONENT", () => { + it("should deserialize BrowserSendComponentState", () => { + const keyDef = BROWSER_SEND_COMPONENT; + + const expectedState = { + typeCounts: new Map(), + }; + + const result = keyDef.deserializer( + JSON.parse(JSON.stringify(expectedState)) as Jsonify, + ); + + expect(result).toEqual(expectedState); + }); + }); + + describe("BROWSER_SEND_TYPE_COMPONENT", () => { + it("should deserialize BrowserComponentState", () => { + const keyDef = BROWSER_SEND_TYPE_COMPONENT; + + const expectedState = { + scrollY: 0, + searchText: "test", + }; + + const result = keyDef.deserializer(JSON.parse(JSON.stringify(expectedState))); + + expect(result).toEqual(expectedState); + }); + }); +}); diff --git a/apps/browser/src/tools/popup/services/key-definitions.ts b/apps/browser/src/tools/popup/services/key-definitions.ts new file mode 100644 index 0000000000..9b256073f3 --- /dev/null +++ b/apps/browser/src/tools/popup/services/key-definitions.ts @@ -0,0 +1,23 @@ +import { Jsonify } from "type-fest"; + +import { BROWSER_SEND_MEMORY, KeyDefinition } from "@bitwarden/common/platform/state"; + +import { BrowserComponentState } from "../../../models/browserComponentState"; +import { BrowserSendComponentState } from "../../../models/browserSendComponentState"; + +export const BROWSER_SEND_COMPONENT = new KeyDefinition( + BROWSER_SEND_MEMORY, + "browser_send_component", + { + deserializer: (obj: Jsonify) => + BrowserSendComponentState.fromJSON(obj), + }, +); + +export const BROWSER_SEND_TYPE_COMPONENT = new KeyDefinition( + BROWSER_SEND_MEMORY, + "browser_send_type_component", + { + deserializer: (obj: Jsonify) => BrowserComponentState.fromJSON(obj), + }, +); diff --git a/apps/browser/src/vault/background/service_factories/cipher-service.factory.ts b/apps/browser/src/vault/background/service_factories/cipher-service.factory.ts index 8ffeca72bc..57366ea8c0 100644 --- a/apps/browser/src/vault/background/service_factories/cipher-service.factory.ts +++ b/apps/browser/src/vault/background/service_factories/cipher-service.factory.ts @@ -42,6 +42,7 @@ import { i18nServiceFactory, I18nServiceInitOptions, } from "../../../platform/background/service-factories/i18n-service.factory"; +import { stateProviderFactory } from "../../../platform/background/service-factories/state-provider.factory"; import { stateServiceFactory, StateServiceInitOptions, @@ -81,6 +82,7 @@ export function cipherServiceFactory( await encryptServiceFactory(cache, opts), await cipherFileUploadServiceFactory(cache, opts), await configServiceFactory(cache, opts), + await stateProviderFactory(cache, opts), ), ); } diff --git a/apps/browser/src/vault/background/service_factories/folder-service.factory.ts b/apps/browser/src/vault/background/service_factories/folder-service.factory.ts index 72847a0536..0593dc904c 100644 --- a/apps/browser/src/vault/background/service_factories/folder-service.factory.ts +++ b/apps/browser/src/vault/background/service_factories/folder-service.factory.ts @@ -14,11 +14,10 @@ import { i18nServiceFactory, I18nServiceInitOptions, } from "../../../platform/background/service-factories/i18n-service.factory"; -import { stateProviderFactory } from "../../../platform/background/service-factories/state-provider.factory"; import { - stateServiceFactory as stateServiceFactory, - StateServiceInitOptions, -} from "../../../platform/background/service-factories/state-service.factory"; + stateProviderFactory, + StateProviderInitOptions, +} from "../../../platform/background/service-factories/state-provider.factory"; import { cipherServiceFactory, CipherServiceInitOptions } from "./cipher-service.factory"; @@ -28,7 +27,7 @@ export type FolderServiceInitOptions = FolderServiceFactoryOptions & CryptoServiceInitOptions & CipherServiceInitOptions & I18nServiceInitOptions & - StateServiceInitOptions; + StateProviderInitOptions; export function folderServiceFactory( cache: { folderService?: AbstractFolderService } & CachedServices, @@ -43,7 +42,6 @@ export function folderServiceFactory( await cryptoServiceFactory(cache, opts), await i18nServiceFactory(cache, opts), await cipherServiceFactory(cache, opts), - await stateServiceFactory(cache, opts), await stateProviderFactory(cache, opts), ), ); diff --git a/apps/browser/src/vault/popup/components/action-buttons.component.ts b/apps/browser/src/vault/popup/components/action-buttons.component.ts index 624789a5c0..b0e7b318d2 100644 --- a/apps/browser/src/vault/popup/components/action-buttons.component.ts +++ b/apps/browser/src/vault/popup/components/action-buttons.component.ts @@ -6,7 +6,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { EventType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; +import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -31,7 +31,7 @@ export class ActionButtonsComponent implements OnInit, OnDestroy { private i18nService: I18nService, private platformUtilsService: PlatformUtilsService, private eventCollectionService: EventCollectionService, - private totpService: TotpService, + private totpService: TotpServiceAbstraction, private passwordRepromptService: PasswordRepromptService, private billingAccountProfileStateService: BillingAccountProfileStateService, ) {} diff --git a/apps/browser/src/vault/popup/components/fido2/fido2.component.ts b/apps/browser/src/vault/popup/components/fido2/fido2.component.ts index 81d1b88fd8..323d2ab4f2 100644 --- a/apps/browser/src/vault/popup/components/fido2/fido2.component.ts +++ b/apps/browser/src/vault/popup/components/fido2/fido2.component.ts @@ -311,7 +311,7 @@ export class Fido2Component implements OnInit, OnDestroy { } protected async search() { - this.hasSearched = this.searchService.isSearchable(this.searchText); + this.hasSearched = await this.searchService.isSearchable(this.searchText); this.searchPending = true; if (this.hasSearched) { this.displayedCiphers = await this.searchService.searchCiphers( diff --git a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts index b27a986231..a566b054c0 100644 --- a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts +++ b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts @@ -304,7 +304,7 @@ export class AddEditComponent extends BaseAddEditComponent { } private saveCipherState() { - return this.stateService.setAddEditCipherInfo({ + return this.cipherService.setAddEditCipherInfo({ cipher: this.cipher, collectionIds: this.collections == null diff --git a/apps/browser/src/vault/popup/components/vault/current-tab.component.html b/apps/browser/src/vault/popup/components/vault/current-tab.component.html index c971f6c937..fc8b4212ba 100644 --- a/apps/browser/src/vault/popup/components/vault/current-tab.component.html +++ b/apps/browser/src/vault/popup/components/vault/current-tab.component.html @@ -36,19 +36,33 @@ - -

{{ autofillCalloutText }}

+ +

+ {{ unassignedItemsBannerService.bannerText$ | async | i18n }} + {{ "learnMore" | i18n }} +

-

diff --git a/apps/browser/src/vault/popup/components/vault/current-tab.component.ts b/apps/browser/src/vault/popup/components/vault/current-tab.component.ts index d9cf6550fa..4d2674fd70 100644 --- a/apps/browser/src/vault/popup/components/vault/current-tab.component.ts +++ b/apps/browser/src/vault/popup/components/vault/current-tab.component.ts @@ -1,13 +1,16 @@ import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core"; import { Router } from "@angular/router"; -import { Subject, firstValueFrom } from "rxjs"; -import { debounceTime, takeUntil } from "rxjs/operators"; +import { Subject, firstValueFrom, from } from "rxjs"; +import { debounceTime, switchMap, takeUntil } from "rxjs/operators"; +import { UnassignedItemsBannerService } from "@bitwarden/angular/services/unassigned-items-banner.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -53,6 +56,11 @@ export class CurrentTabComponent implements OnInit, OnDestroy { private totpTimeout: number; private loadedTimeout: number; private searchTimeout: number; + private initPageDetailsTimeout: number; + + protected unassignedItemsBannerEnabled$ = this.configService.getFeatureFlag$( + FeatureFlag.UnassignedItemsBanner, + ); constructor( private platformUtilsService: PlatformUtilsService, @@ -70,6 +78,8 @@ export class CurrentTabComponent implements OnInit, OnDestroy { private organizationService: OrganizationService, private vaultFilterService: VaultFilterService, private vaultSettingsService: VaultSettingsService, + private configService: ConfigService, + protected unassignedItemsBannerService: UnassignedItemsBannerService, ) {} async ngOnInit() { @@ -120,8 +130,14 @@ export class CurrentTabComponent implements OnInit, OnDestroy { } this.search$ - .pipe(debounceTime(500), takeUntil(this.destroy$)) - .subscribe(() => this.searchVault()); + .pipe( + debounceTime(500), + switchMap(() => { + return from(this.searchVault()); + }), + takeUntil(this.destroy$), + ) + .subscribe(); const autofillOnPageLoadOrgPolicy = await firstValueFrom( this.autofillSettingsService.activateAutofillOnPageLoadFromPolicy$, @@ -232,14 +248,12 @@ export class CurrentTabComponent implements OnInit, OnDestroy { } } - searchVault() { - if (!this.searchService.isSearchable(this.searchText)) { + async searchVault() { + if (!(await this.searchService.isSearchable(this.searchText))) { return; } - // 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.router.navigate(["/tabs/vault"], { queryParams: { searchText: this.searchText } }); + await this.router.navigate(["/tabs/vault"], { queryParams: { searchText: this.searchText } }); } closeOnEsc(e: KeyboardEvent) { @@ -303,18 +317,13 @@ export class CurrentTabComponent implements OnInit, OnDestroy { }); if (this.loginCiphers.length) { - void BrowserApi.tabSendMessage(this.tab, { - command: "collectPageDetails", - tab: this.tab, - sender: BroadcasterSubscriptionId, - }); - this.loginCiphers = this.loginCiphers.sort((a, b) => this.cipherService.sortCiphersByLastUsedThenName(a, b), ); } this.isLoading = this.loaded = true; + this.collectTabPageDetails(); } async goToSettings() { @@ -352,4 +361,19 @@ export class CurrentTabComponent implements OnInit, OnDestroy { this.autofillCalloutText = this.i18nService.t("autofillSelectInfoWithoutCommand"); } } + + private collectTabPageDetails() { + void BrowserApi.tabSendMessage(this.tab, { + command: "collectPageDetails", + tab: this.tab, + sender: BroadcasterSubscriptionId, + }); + + window.clearTimeout(this.initPageDetailsTimeout); + this.initPageDetailsTimeout = window.setTimeout(() => { + if (this.pageDetails.length === 0) { + this.collectTabPageDetails(); + } + }, 250); + } } 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 2510e2f966..deb4434df4 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 @@ -1,8 +1,8 @@ import { Location } from "@angular/common"; import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; -import { firstValueFrom } from "rxjs"; -import { first } from "rxjs/operators"; +import { BehaviorSubject, Subject, firstValueFrom, from } from "rxjs"; +import { first, switchMap, takeUntil } from "rxjs/operators"; import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; @@ -53,7 +53,6 @@ export class VaultFilterComponent implements OnInit, OnDestroy { folderCounts = new Map(); collectionCounts = new Map(); typeCounts = new Map(); - searchText: string; state: BrowserGroupingsComponentState; showLeftHeader = true; searchPending = false; @@ -71,6 +70,16 @@ export class VaultFilterComponent implements OnInit, OnDestroy { private hasSearched = false; private hasLoadedAllCiphers = false; private allCiphers: CipherView[] = null; + private destroy$ = new Subject(); + private _searchText$ = new BehaviorSubject(""); + private isSearchable: boolean = false; + + get searchText() { + return this._searchText$.value; + } + set searchText(value: string) { + this._searchText$.next(value); + } constructor( private i18nService: I18nService, @@ -148,6 +157,15 @@ export class VaultFilterComponent implements OnInit, OnDestroy { BrowserPopupUtils.setContentScrollY(window, this.state?.scrollY); } }); + + this._searchText$ + .pipe( + switchMap((searchText) => from(this.searchService.isSearchable(searchText))), + takeUntil(this.destroy$), + ) + .subscribe((isSearchable) => { + this.isSearchable = isSearchable; + }); } ngOnDestroy() { @@ -161,6 +179,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.saveState(); this.broadcasterService.unsubscribe(ComponentId); + this.destroy$.next(); + this.destroy$.complete(); } async load() { @@ -181,7 +201,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { async loadCiphers() { this.allCiphers = await this.cipherService.getAllDecrypted(); if (!this.hasLoadedAllCiphers) { - this.hasLoadedAllCiphers = !this.searchService.isSearchable(this.searchText); + this.hasLoadedAllCiphers = !(await this.searchService.isSearchable(this.searchText)); } await this.search(null); this.getCounts(); @@ -210,7 +230,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { } const filterDeleted = (c: CipherView) => !c.isDeleted; if (timeout == null) { - this.hasSearched = this.searchService.isSearchable(this.searchText); + this.hasSearched = this.isSearchable; this.ciphers = await this.searchService.searchCiphers( this.searchText, filterDeleted, @@ -223,7 +243,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { } this.searchPending = true; this.searchTimeout = setTimeout(async () => { - this.hasSearched = this.searchService.isSearchable(this.searchText); + this.hasSearched = this.isSearchable; if (!this.hasLoadedAllCiphers && !this.hasSearched) { await this.loadCiphers(); } else { @@ -381,9 +401,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { } showSearching() { - return ( - this.hasSearched || (!this.searchPending && this.searchService.isSearchable(this.searchText)) - ); + return this.hasSearched || (!this.searchPending && this.isSearchable); } closeOnEsc(e: KeyboardEvent) { diff --git a/apps/browser/src/vault/popup/components/vault/view.component.ts b/apps/browser/src/vault/popup/components/vault/view.component.ts index d7ef15afb7..a225db0c11 100644 --- a/apps/browser/src/vault/popup/components/vault/view.component.ts +++ b/apps/browser/src/vault/popup/components/vault/view.component.ts @@ -20,7 +20,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; -import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; +import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; @@ -74,7 +74,7 @@ export class ViewComponent extends BaseViewComponent { constructor( cipherService: CipherService, folderService: FolderService, - totpService: TotpService, + totpService: TotpServiceAbstraction, tokenService: TokenService, i18nService: I18nService, cryptoService: CryptoService, diff --git a/apps/cli/src/auth/commands/unlock.command.ts b/apps/cli/src/auth/commands/unlock.command.ts index 98bc926079..d52468139a 100644 --- a/apps/cli/src/auth/commands/unlock.command.ts +++ b/apps/cli/src/auth/commands/unlock.command.ts @@ -1,6 +1,10 @@ +import { firstValueFrom } from "rxjs"; + import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -18,6 +22,8 @@ import { CliUtils } from "../../utils"; export class UnlockCommand { constructor( + private accountService: AccountService, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, private cryptoService: CryptoService, private stateService: StateService, private cryptoFunctionService: CryptoFunctionService, @@ -45,11 +51,14 @@ export class UnlockCommand { const kdf = await this.stateService.getKdfType(); const kdfConfig = await this.stateService.getKdfConfig(); const masterKey = await this.cryptoService.makeMasterKey(password, email, kdf, kdfConfig); - const storedKeyHash = await this.cryptoService.getMasterKeyHash(); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + const storedMasterKeyHash = await firstValueFrom( + this.masterPasswordService.masterKeyHash$(userId), + ); let passwordValid = false; if (masterKey != null) { - if (storedKeyHash != null) { + if (storedMasterKeyHash != null) { passwordValid = await this.cryptoService.compareAndUpdateKeyHash(password, masterKey); } else { const serverKeyHash = await this.cryptoService.hashMasterKey( @@ -67,7 +76,7 @@ export class UnlockCommand { masterKey, HashPurpose.LocalAuthorization, ); - await this.cryptoService.setMasterKeyHash(localKeyHash); + await this.masterPasswordService.setMasterKeyHash(localKeyHash, userId); } catch { // Ignore } @@ -75,7 +84,7 @@ export class UnlockCommand { } if (passwordValid) { - await this.cryptoService.setMasterKey(masterKey); + await this.masterPasswordService.setMasterKey(masterKey, userId); const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey); await this.cryptoService.setUserKey(userKey); diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index 0e6571f775..f02d7da49c 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -28,12 +28,14 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { AvatarService as AvatarServiceAbstraction } from "@bitwarden/common/auth/abstractions/avatar.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { AvatarService } from "@bitwarden/common/auth/services/avatar.service"; import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device-trust-crypto.service.implementation"; import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; import { KeyConnectorService } from "@bitwarden/common/auth/services/key-connector.service"; +import { MasterPasswordService } from "@bitwarden/common/auth/services/master-password/master-password.service"; import { TokenService } from "@bitwarden/common/auth/services/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service"; import { UserVerificationApiService } from "@bitwarden/common/auth/services/user-verification/user-verification-api.service"; @@ -168,6 +170,7 @@ export class Main { organizationUserService: OrganizationUserService; collectionService: CollectionService; vaultTimeoutService: VaultTimeoutService; + masterPasswordService: InternalMasterPasswordServiceAbstraction; vaultTimeoutSettingsService: VaultTimeoutSettingsService; syncService: SyncService; eventCollectionService: EventCollectionServiceAbstraction; @@ -351,7 +354,10 @@ export class Main { migrationRunner, ); + this.masterPasswordService = new MasterPasswordService(this.stateProvider); + this.cryptoService = new CryptoService( + this.masterPasswordService, this.keyGenerationService, this.cryptoFunctionService, this.encryptService, @@ -411,7 +417,7 @@ export class Main { this.sendService, ); - this.searchService = new SearchService(this.logService, this.i18nService); + this.searchService = new SearchService(this.logService, this.i18nService, this.stateProvider); this.broadcasterService = new BroadcasterService(); @@ -432,6 +438,8 @@ export class Main { this.policyApiService = new PolicyApiService(this.policyService, this.apiService); this.keyConnectorService = new KeyConnectorService( + this.accountService, + this.masterPasswordService, this.cryptoService, this.apiService, this.tokenService, @@ -471,9 +479,11 @@ export class Main { this.authRequestService = new AuthRequestService( this.appIdService, + this.accountService, + this.masterPasswordService, this.cryptoService, this.apiService, - this.stateService, + this.stateProvider, ); this.billingAccountProfileStateService = new DefaultBillingAccountProfileStateService( @@ -481,6 +491,8 @@ export class Main { ); this.loginStrategyService = new LoginStrategyService( + this.accountService, + this.masterPasswordService, this.cryptoService, this.apiService, this.tokenService, @@ -532,13 +544,13 @@ export class Main { this.encryptService, this.cipherFileUploadService, this.configService, + this.stateProvider, ); this.folderService = new FolderService( this.cryptoService, this.i18nService, this.cipherService, - this.stateService, this.stateProvider, ); @@ -568,6 +580,8 @@ export class Main { this.userVerificationService = new UserVerificationService( this.stateService, this.cryptoService, + this.accountService, + this.masterPasswordService, this.i18nService, this.userVerificationApiService, this.userDecryptionOptionsService, @@ -578,6 +592,8 @@ export class Main { ); this.vaultTimeoutService = new VaultTimeoutService( + this.accountService, + this.masterPasswordService, this.cipherService, this.folderService, this.collectionService, @@ -596,6 +612,8 @@ export class Main { this.avatarService = new AvatarService(this.apiService, this.stateProvider); this.syncService = new SyncService( + this.masterPasswordService, + this.accountService, this.apiService, this.domainSettingsService, this.folderService, @@ -664,7 +682,7 @@ export class Main { this.apiService, this.stateProvider, this.logService, - this.accountService, + this.authService, ); this.eventCollectionService = new EventCollectionService( @@ -672,7 +690,7 @@ export class Main { this.stateProvider, this.organizationService, this.eventUploadService, - this.accountService, + this.authService, ); } diff --git a/apps/cli/src/commands/serve.command.ts b/apps/cli/src/commands/serve.command.ts index 4d0d1e5798..76447f769c 100644 --- a/apps/cli/src/commands/serve.command.ts +++ b/apps/cli/src/commands/serve.command.ts @@ -122,6 +122,8 @@ export class ServeCommand { this.shareCommand = new ShareCommand(this.main.cipherService); this.lockCommand = new LockCommand(this.main.vaultTimeoutService); this.unlockCommand = new UnlockCommand( + this.main.accountService, + this.main.masterPasswordService, this.main.cryptoService, this.main.stateService, this.main.cryptoFunctionService, diff --git a/apps/cli/src/platform/flags.ts b/apps/cli/src/platform/flags.ts index 4e31e39e99..dc0103e243 100644 --- a/apps/cli/src/platform/flags.ts +++ b/apps/cli/src/platform/flags.ts @@ -7,11 +7,11 @@ import { } from "@bitwarden/common/platform/misc/flags"; // required to avoid linting errors when there are no flags -/* eslint-disable-next-line @typescript-eslint/ban-types */ +// eslint-disable-next-line @typescript-eslint/ban-types export type Flags = {} & SharedFlags; // required to avoid linting errors when there are no flags -/* eslint-disable-next-line @typescript-eslint/ban-types */ +// eslint-disable-next-line @typescript-eslint/ban-types export type DevFlags = {} & SharedDevFlags; export function flagEnabled(flag: keyof Flags): boolean { diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index a79f3847da..fa71a88f54 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -253,6 +253,8 @@ export class Program { if (!cmd.check) { await this.exitIfNotAuthed(); const command = new UnlockCommand( + this.main.accountService, + this.main.masterPasswordService, this.main.cryptoService, this.main.stateService, this.main.cryptoFunctionService, @@ -613,6 +615,8 @@ export class Program { this.processResponse(response, true); } else { const command = new UnlockCommand( + this.main.accountService, + this.main.masterPasswordService, this.main.cryptoService, this.main.stateService, this.main.cryptoFunctionService, diff --git a/apps/desktop/config/development.json b/apps/desktop/config/development.json index d2b1073812..7a8659feff 100644 --- a/apps/desktop/config/development.json +++ b/apps/desktop/config/development.json @@ -1,7 +1,6 @@ { "devFlags": {}, "flags": { - "showDDGSetting": true, "enableCipherKeyEncryption": false } } diff --git a/apps/desktop/config/production.json b/apps/desktop/config/production.json index 39b78094d0..f57c3d9bc3 100644 --- a/apps/desktop/config/production.json +++ b/apps/desktop/config/production.json @@ -1,6 +1,5 @@ { "flags": { - "showDDGSetting": true, "enableCipherKeyEncryption": false } } diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 7646b63001..b921cab37b 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -1237,18 +1237,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.51" +version = "1.0.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f11c217e1416d6f036b870f14e0413d480dbf28edbee1f877abaf0206af43bb7" +checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.51" +version = "1.0.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01742297787513b79cf8e29d1056ede1313e2420b7b3b15d0a768b4921f549df" +checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" dependencies = [ "proc-macro2", "quote", diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index a1625020e5..4b2bc2e905 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -24,7 +24,7 @@ rand = "=0.8.5" retry = "=2.0.0" scopeguard = "=1.2.0" sha2 = "=0.10.8" -thiserror = "=1.0.51" +thiserror = "=1.0.58" typenum = "=1.17.0" [build-dependencies] diff --git a/apps/desktop/electron-builder.json b/apps/desktop/electron-builder.json index 5ce5ef948f..5fd26f32ba 100644 --- a/apps/desktop/electron-builder.json +++ b/apps/desktop/electron-builder.json @@ -203,7 +203,7 @@ "si", "sk", "sl", - "sr", + "sr-cyrl", "sv", "te", "th", @@ -228,6 +228,7 @@ "artifactName": "${productName}-${version}-${arch}.${ext}" }, "snap": { + "summary": "After installation enable required `password-manager-service` by running `sudo snap connect bitwarden:password-manager-service`.", "autoStart": true, "base": "core22", "confinement": "strict", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 52dd0fafdb..0dc23b04b1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2024.3.2", + "version": "2024.4.1", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index a613328878..5f59530d8c 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -3,6 +3,7 @@ import { FormBuilder } from "@angular/forms"; import { BehaviorSubject, firstValueFrom, Observable, Subject } from "rxjs"; import { concatMap, debounceTime, filter, map, switchMap, takeUntil, tap } from "rxjs/operators"; +import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common"; import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; @@ -20,11 +21,11 @@ import { BiometricStateService } from "@bitwarden/common/platform/biometrics/bio import { ThemeType, KeySuffixOptions } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; +import { UserId } from "@bitwarden/common/types/guid"; import { DialogService } from "@bitwarden/components"; import { SetPinComponent } from "../../auth/components/set-pin.component"; import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service"; -import { flagEnabled } from "../../platform/flags"; import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; @Component({ @@ -61,6 +62,7 @@ export class SettingsComponent implements OnInit { showAppPreferences = true; currentUserEmail: string; + currentUserId: UserId; availableVaultTimeoutActions$: Observable; vaultTimeoutPolicyCallout: Observable<{ @@ -123,6 +125,7 @@ export class SettingsComponent implements OnInit { private desktopSettingsService: DesktopSettingsService, private biometricStateService: BiometricStateService, private desktopAutofillSettingsService: DesktopAutofillSettingsService, + private authRequestService: AuthRequestServiceAbstraction, ) { const isMac = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop; @@ -146,7 +149,7 @@ export class SettingsComponent implements OnInit { this.startToTrayDescText = this.i18nService.t(startToTrayKey + "Desc"); // DuckDuckGo browser is only for macos initially - this.showDuckDuckGoIntegrationOption = flagEnabled("showDDGSetting") && isMac; + this.showDuckDuckGoIntegrationOption = isMac; this.vaultTimeoutOptions = [ // { name: i18nService.t('immediately'), value: 0 }, @@ -208,6 +211,7 @@ export class SettingsComponent implements OnInit { return; } this.currentUserEmail = await this.stateService.getEmail(); + this.currentUserId = (await this.stateService.getUserId()) as UserId; this.availableVaultTimeoutActions$ = this.refreshTimeoutSettings$.pipe( switchMap(() => this.vaultTimeoutSettingsService.availableVaultTimeoutActions$()), @@ -250,7 +254,8 @@ export class SettingsComponent implements OnInit { requirePasswordOnStart: await firstValueFrom( this.biometricStateService.requirePasswordOnStart$, ), - approveLoginRequests: (await this.stateService.getApproveLoginRequests()) ?? false, + approveLoginRequests: + (await this.authRequestService.getAcceptAuthRequests(this.currentUserId)) ?? false, clearClipboard: await firstValueFrom(this.autofillSettingsService.clearClipboardDelay$), minimizeOnCopyToClipboard: await this.stateService.getMinimizeOnCopyToClipboard(), enableFavicons: await firstValueFrom(this.domainSettingsService.showFavicons$), @@ -666,7 +671,10 @@ export class SettingsComponent implements OnInit { } async updateApproveLoginRequests() { - await this.stateService.setApproveLoginRequests(this.form.value.approveLoginRequests); + await this.authRequestService.setAcceptAuthRequests( + this.form.value.approveLoginRequests, + this.currentUserId, + ); } ngOnDestroy() { diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 884296ea29..b2b44e6b21 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -26,6 +26,7 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti import { ProviderService } from "@bitwarden/common/admin-console/abstractions/provider.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; +import { MasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; @@ -120,6 +121,7 @@ export class AppComponent implements OnInit, OnDestroy { private accountCleanUpInProgress: { [userId: string]: boolean } = {}; constructor( + private masterPasswordService: MasterPasswordServiceAbstraction, private broadcasterService: BroadcasterService, private folderService: InternalFolderService, private syncService: SyncService, @@ -408,8 +410,9 @@ export class AppComponent implements OnInit, OnDestroy { (await this.authService.getAuthStatus(message.userId)) === AuthenticationStatus.Locked; const forcedPasswordReset = - (await this.stateService.getForceSetPasswordReason({ userId: message.userId })) != - ForceSetPasswordReason.None; + (await firstValueFrom( + this.masterPasswordService.forceSetPasswordReason$(message.userId), + )) != ForceSetPasswordReason.None; if (locked) { this.messagingService.send("locked", { userId: message.userId }); } else if (forcedPasswordReset) { @@ -606,7 +609,6 @@ export class AppComponent implements OnInit, OnDestroy { // This must come last otherwise the logout will prematurely trigger // a process reload before all the state service user data can be cleaned up if (userBeingLoggedOut === preLogoutActiveUserId) { - this.searchService.clearIndex(); this.authService.logOut(async () => { if (expired) { this.platformUtilsService.showToast( diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 3b38092bd7..4de4b589d3 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -20,6 +20,7 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul import { PolicyService as PolicyServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/common/platform/abstractions/broadcaster.service"; @@ -230,6 +231,7 @@ const safeProviders: SafeProvider[] = [ provide: CryptoServiceAbstraction, useClass: ElectronCryptoService, deps: [ + InternalMasterPasswordServiceAbstraction, KeyGenerationServiceAbstraction, CryptoFunctionServiceAbstraction, EncryptService, diff --git a/apps/desktop/src/app/tools/generator.component.spec.ts b/apps/desktop/src/app/tools/generator.component.spec.ts index 53f919a596..51b5bf93a2 100644 --- a/apps/desktop/src/app/tools/generator.component.spec.ts +++ b/apps/desktop/src/app/tools/generator.component.spec.ts @@ -10,6 +10,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { GeneratorComponent } from "./generator.component"; @@ -54,6 +55,10 @@ describe("GeneratorComponent", () => { provide: LogService, useValue: mock(), }, + { + provide: CipherService, + useValue: mock(), + }, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); diff --git a/apps/desktop/src/auth/lock.component.spec.ts b/apps/desktop/src/auth/lock.component.spec.ts index 0339889bf7..c125eba022 100644 --- a/apps/desktop/src/auth/lock.component.spec.ts +++ b/apps/desktop/src/auth/lock.component.spec.ts @@ -14,7 +14,9 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs 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 { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -52,6 +54,7 @@ describe("LockComponent", () => { let broadcasterServiceMock: MockProxy; let platformUtilsServiceMock: MockProxy; let activatedRouteMock: MockProxy; + let mockMasterPasswordService: FakeMasterPasswordService; const mockUserId = Utils.newGuid() as UserId; const accountService: FakeAccountService = mockAccountServiceWith(mockUserId); @@ -67,6 +70,8 @@ describe("LockComponent", () => { activatedRouteMock = mock(); activatedRouteMock.queryParams = mock(); + mockMasterPasswordService = new FakeMasterPasswordService(); + biometricStateService.dismissedRequirePasswordOnStartCallout$ = of(false); biometricStateService.promptAutomatically$ = of(false); biometricStateService.promptCancelled$ = of(false); @@ -74,6 +79,7 @@ describe("LockComponent", () => { await TestBed.configureTestingModule({ declarations: [LockComponent, I18nPipe], providers: [ + { provide: InternalMasterPasswordServiceAbstraction, useValue: mockMasterPasswordService }, { provide: I18nService, useValue: mock(), diff --git a/apps/desktop/src/auth/lock.component.ts b/apps/desktop/src/auth/lock.component.ts index 8b1448c06f..16b58c5bbe 100644 --- a/apps/desktop/src/auth/lock.component.ts +++ b/apps/desktop/src/auth/lock.component.ts @@ -11,6 +11,7 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs 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 { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { DeviceType } from "@bitwarden/common/enums"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; @@ -38,6 +39,7 @@ export class LockComponent extends BaseLockComponent { private autoPromptBiometric = false; constructor( + masterPasswordService: InternalMasterPasswordServiceAbstraction, router: Router, i18nService: I18nService, platformUtilsService: PlatformUtilsService, @@ -63,6 +65,7 @@ export class LockComponent extends BaseLockComponent { accountService: AccountService, ) { super( + masterPasswordService, router, i18nService, platformUtilsService, diff --git a/apps/desktop/src/auth/set-password.component.ts b/apps/desktop/src/auth/set-password.component.ts index a75668a856..93dfe0abd8 100644 --- a/apps/desktop/src/auth/set-password.component.ts +++ b/apps/desktop/src/auth/set-password.component.ts @@ -8,6 +8,8 @@ import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-conso import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service"; import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -29,6 +31,8 @@ const BroadcasterSubscriptionId = "SetPasswordComponent"; }) export class SetPasswordComponent extends BaseSetPasswordComponent implements OnDestroy { constructor( + accountService: AccountService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, apiService: ApiService, i18nService: I18nService, cryptoService: CryptoService, @@ -50,6 +54,8 @@ export class SetPasswordComponent extends BaseSetPasswordComponent implements On dialogService: DialogService, ) { super( + accountService, + masterPasswordService, i18nService, cryptoService, messagingService, diff --git a/apps/desktop/src/auth/sso.component.ts b/apps/desktop/src/auth/sso.component.ts index 210319b9ed..cc261f1235 100644 --- a/apps/desktop/src/auth/sso.component.ts +++ b/apps/desktop/src/auth/sso.component.ts @@ -7,6 +7,8 @@ import { UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; @@ -39,6 +41,8 @@ export class SsoComponent extends BaseSsoComponent { logService: LogService, userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, configService: ConfigService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, + accountService: AccountService, ) { super( ssoLoginService, @@ -55,6 +59,8 @@ export class SsoComponent extends BaseSsoComponent { logService, userDecryptionOptionsService, configService, + masterPasswordService, + accountService, ); super.onSuccessfulLogin = async () => { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. diff --git a/apps/desktop/src/auth/two-factor.component.ts b/apps/desktop/src/auth/two-factor.component.ts index fdbc52b4bf..d1b84c1fa0 100644 --- a/apps/desktop/src/auth/two-factor.component.ts +++ b/apps/desktop/src/auth/two-factor.component.ts @@ -11,6 +11,8 @@ import { UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; @@ -60,6 +62,8 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, ssoLoginService: SsoLoginServiceAbstraction, configService: ConfigService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, + accountService: AccountService, @Inject(WINDOW) protected win: Window, ) { super( @@ -79,6 +83,8 @@ export class TwoFactorComponent extends BaseTwoFactorComponent { userDecryptionOptionsService, ssoLoginService, configService, + masterPasswordService, + accountService, ); super.onSuccessfulLogin = async () => { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. diff --git a/apps/desktop/src/locales/ca/messages.json b/apps/desktop/src/locales/ca/messages.json index 9f341f926f..c76111b53c 100644 --- a/apps/desktop/src/locales/ca/messages.json +++ b/apps/desktop/src/locales/ca/messages.json @@ -2698,9 +2698,9 @@ "message": "Activeu l'acceleració i reinicieu el maquinari" }, "removePasskey": { - "message": "Remove passkey" + "message": "Suprimeix la clau de pas" }, "passkeyRemoved": { - "message": "Passkey removed" + "message": "Clau de pas suprimida" } } diff --git a/apps/desktop/src/locales/de/messages.json b/apps/desktop/src/locales/de/messages.json index a07ad93b15..428cfd6a27 100644 --- a/apps/desktop/src/locales/de/messages.json +++ b/apps/desktop/src/locales/de/messages.json @@ -2698,9 +2698,9 @@ "message": "Hardwarebeschleunigung aktivieren und neu starten" }, "removePasskey": { - "message": "Passkey löschen" + "message": "Passkey entfernen" }, "passkeyRemoved": { - "message": "Passkey gelöscht" + "message": "Passkey entfernt" } } diff --git a/apps/desktop/src/locales/fi/messages.json b/apps/desktop/src/locales/fi/messages.json index 412e8fc20f..f74136aedc 100644 --- a/apps/desktop/src/locales/fi/messages.json +++ b/apps/desktop/src/locales/fi/messages.json @@ -494,7 +494,7 @@ "message": "Kansio poistettiin" }, "loginOrCreateNewAccount": { - "message": "Käytä salattua holviasi kirjautumalla sisään tai tai luo uusi tili." + "message": "Käytä salattua holviasi kirjautumalla sisään tai luo uusi tili." }, "createAccount": { "message": "Luo tili" @@ -2689,7 +2689,7 @@ "description": "Label indicating the most common import formats" }, "troubleshooting": { - "message": "Vianmääritys" + "message": "Vianetsintä" }, "disableHardwareAccelerationRestart": { "message": "Poista laitteistokiihdytys käytöstä ja käynnistä sovellus uudelleen" diff --git a/apps/desktop/src/locales/ru/messages.json b/apps/desktop/src/locales/ru/messages.json index 357a5757ce..0e28c2cf90 100644 --- a/apps/desktop/src/locales/ru/messages.json +++ b/apps/desktop/src/locales/ru/messages.json @@ -2480,13 +2480,13 @@ "message": "Перейти к содержимому" }, "typePasskey": { - "message": "Ключ доступа" + "message": "Passkey" }, "passkeyNotCopied": { - "message": "Ключ доступа не будет скопирован" + "message": "Passkey не будет скопирован" }, "passkeyNotCopiedAlert": { - "message": "Ключ доступа не будет скопирован в клонированный элемент. Продолжить клонирование этого элемента?" + "message": "Passkey не будет скопирован в клонированный элемент. Продолжить клонирование этого элемента?" }, "aliasDomain": { "message": "Псевдоним домена" diff --git a/apps/desktop/src/locales/sv/messages.json b/apps/desktop/src/locales/sv/messages.json index 4c41cee471..c07f7efef3 100644 --- a/apps/desktop/src/locales/sv/messages.json +++ b/apps/desktop/src/locales/sv/messages.json @@ -2689,7 +2689,7 @@ "description": "Label indicating the most common import formats" }, "troubleshooting": { - "message": "Troubleshooting" + "message": "Felsökning" }, "disableHardwareAccelerationRestart": { "message": "Disable hardware acceleration and restart" @@ -2698,9 +2698,9 @@ "message": "Enable hardware acceleration and restart" }, "removePasskey": { - "message": "Remove passkey" + "message": "Ta bort nyckel" }, "passkeyRemoved": { - "message": "Passkey removed" + "message": "Nyckel borttagen" } } diff --git a/apps/desktop/src/locales/zh_CN/messages.json b/apps/desktop/src/locales/zh_CN/messages.json index 2c3401de6b..8725fa0f21 100644 --- a/apps/desktop/src/locales/zh_CN/messages.json +++ b/apps/desktop/src/locales/zh_CN/messages.json @@ -1907,7 +1907,7 @@ "message": "无法完成生物识别。" }, "needADifferentMethod": { - "message": "Need a different method?" + "message": "使用其他方式?" }, "useMasterPassword": { "message": "使用主密码" diff --git a/apps/desktop/src/locales/zh_TW/messages.json b/apps/desktop/src/locales/zh_TW/messages.json index d124fc7d58..c93f236976 100644 --- a/apps/desktop/src/locales/zh_TW/messages.json +++ b/apps/desktop/src/locales/zh_TW/messages.json @@ -404,7 +404,7 @@ "message": "長度" }, "passwordMinLength": { - "message": "Minimum password length" + "message": "最小密碼長度" }, "uppercase": { "message": "大寫 (A-Z)" @@ -561,10 +561,10 @@ "message": "帳戶已建立!現在可以登入了。" }, "youSuccessfullyLoggedIn": { - "message": "You successfully logged in" + "message": "你已成功登入" }, "youMayCloseThisWindow": { - "message": "You may close this window" + "message": "你可以關閉此視窗" }, "masterPassSent": { "message": "已寄出包含您主密碼提示的電子郵件。" @@ -1546,15 +1546,15 @@ "message": "設定主密碼" }, "orgPermissionsUpdatedMustSetPassword": { - "message": "Your organization permissions were updated, requiring you to set a master password.", + "message": "你的組織權限已更新,要求你設定一個主密碼。", "description": "Used as a card title description on the set password page to explain why the user is there" }, "orgRequiresYouToSetPassword": { - "message": "Your organization requires you to set a master password.", + "message": "你的組織要求你設定一個主密碼。", "description": "Used as a card title description on the set password page to explain why the user is there" }, "verificationRequired": { - "message": "Verification required", + "message": "需要驗證", "description": "Default title for the user verification dialog." }, "currentMasterPass": { @@ -1645,10 +1645,10 @@ "message": "在您的桌面和瀏覽器閒建立連綫時,透過要求指紋短語確認,以添加一個額外的安全層。每次建立連綫都需要使用者干預和驗證。" }, "enableHardwareAcceleration": { - "message": "Use hardware acceleration" + "message": "使用硬體加速" }, "enableHardwareAccelerationDesc": { - "message": "By default this setting is ON. Turn OFF only if you experience graphical issues. Restart is required." + "message": "此設定預設為開啟。僅當你遇到圖形問題時才關閉。需要重新啟動。" }, "approve": { "message": "核准" @@ -1889,40 +1889,40 @@ "message": "您的主密碼不符合一個或多個組織原則要求。您必須立即更新您的主密碼才能存取密碼庫。進行此動作將登出您目前的工作階段,需要您重新登入。其他裝置上的工作階段可能繼續長達一小時。" }, "tryAgain": { - "message": "Try again" + "message": "再試一次" }, "verificationRequiredForActionSetPinToContinue": { - "message": "Verification required for this action. Set a PIN to continue." + "message": "此操作需要驗證。設定 PIN 碼以繼續。" }, "setPin": { - "message": "Set PIN" + "message": "設定 PIN 碼" }, "verifyWithBiometrics": { - "message": "Verify with biometrics" + "message": "使用生物辨識進行驗證" }, "awaitingConfirmation": { - "message": "Awaiting confirmation" + "message": "正在等待確認" }, "couldNotCompleteBiometrics": { - "message": "Could not complete biometrics." + "message": "無法完成生物辨識。" }, "needADifferentMethod": { - "message": "Need a different method?" + "message": "需要不同的方法嗎?" }, "useMasterPassword": { - "message": "Use master password" + "message": "使用主密碼" }, "usePin": { - "message": "Use PIN" + "message": "使用 PIN 碼" }, "useBiometrics": { - "message": "Use biometrics" + "message": "使用生物辨識" }, "enterVerificationCodeSentToEmail": { "message": "Enter the verification code that was sent to your email." }, "resendCode": { - "message": "Resend code" + "message": "重新傳送驗證碼" }, "hours": { "message": "小時" @@ -2465,7 +2465,7 @@ "message": "全部清除" }, "plusNMore": { - "message": "+ $QUANTITY$ more", + "message": "+ $QUANTITY$ 個更多", "placeholders": { "quantity": { "content": "$1", @@ -2477,7 +2477,7 @@ "message": "子選單" }, "skipToContent": { - "message": "Skip to content" + "message": "跳至內容" }, "typePasskey": { "message": "密碼金鑰" @@ -2621,13 +2621,13 @@ "message": "使用者名稱或密碼不正確" }, "incorrectPassword": { - "message": "Incorrect password" + "message": "密碼不正確" }, "incorrectCode": { - "message": "Incorrect code" + "message": "驗證碼不正確" }, "incorrectPin": { - "message": "Incorrect PIN" + "message": "PIN 碼不正確" }, "multifactorAuthenticationFailed": { "message": "多因素驗證失敗" @@ -2685,22 +2685,22 @@ "message": "將與您的 LastPass 帳戶關聯的 YubiKey 插入電腦的 USB 連接埠,然後觸摸其按鈕。" }, "commonImportFormats": { - "message": "Common formats", + "message": "常見格式", "description": "Label indicating the most common import formats" }, "troubleshooting": { - "message": "Troubleshooting" + "message": "疑難排解" }, "disableHardwareAccelerationRestart": { - "message": "Disable hardware acceleration and restart" + "message": "停用硬體加速並重新啟動" }, "enableHardwareAccelerationRestart": { - "message": "Enable hardware acceleration and restart" + "message": "啟用硬體加速並重新啟動" }, "removePasskey": { - "message": "Remove passkey" + "message": "移除金鑰" }, "passkeyRemoved": { - "message": "Passkey removed" + "message": "金鑰已移除" } } diff --git a/apps/desktop/src/main/updater.main.ts b/apps/desktop/src/main/updater.main.ts index c9a2dc0e14..0e2efa66f9 100644 --- a/apps/desktop/src/main/updater.main.ts +++ b/apps/desktop/src/main/updater.main.ts @@ -27,8 +27,7 @@ export class UpdaterMain { process.platform === "win32" && !isWindowsStore() && !isWindowsPortable(); const macCanUpdate = process.platform === "darwin" && !isMacAppStore(); this.canUpdate = - process.env.ELECTRON_NO_UPDATER !== "1" && - (linuxCanUpdate || windowsCanUpdate || macCanUpdate); + !this.userDisabledUpdates() && (linuxCanUpdate || windowsCanUpdate || macCanUpdate); } async init() { @@ -144,4 +143,13 @@ export class UpdaterMain { autoUpdater.autoDownload = true; this.doingUpdateCheck = false; } + + private userDisabledUpdates(): boolean { + for (const arg of process.argv) { + if (arg != null && arg.toUpperCase().indexOf("--ELECTRON_NO_UPDATER=1") > -1) { + return true; + } + } + return process.env.ELECTRON_NO_UPDATER === "1"; + } } diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index 167c32cc81..0531345131 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2024.3.2", + "version": "2024.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2024.3.2", + "version": "2024.4.1", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-native": "file:../desktop_native", diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index cfc0b9b4e2..6527c21521 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2024.3.2", + "version": "2024.4.1", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/apps/desktop/src/platform/flags.ts b/apps/desktop/src/platform/flags.ts index 0481c8ce9b..dc0103e243 100644 --- a/apps/desktop/src/platform/flags.ts +++ b/apps/desktop/src/platform/flags.ts @@ -7,13 +7,11 @@ import { } from "@bitwarden/common/platform/misc/flags"; // required to avoid linting errors when there are no flags -/* eslint-disable-next-line @typescript-eslint/ban-types */ -export type Flags = { - showDDGSetting?: boolean; -} & SharedFlags; +// eslint-disable-next-line @typescript-eslint/ban-types +export type Flags = {} & SharedFlags; // required to avoid linting errors when there are no flags -/* eslint-disable-next-line @typescript-eslint/ban-types */ +// eslint-disable-next-line @typescript-eslint/ban-types export type DevFlags = {} & SharedDevFlags; export function flagEnabled(flag: keyof Flags): boolean { diff --git a/apps/desktop/src/platform/services/electron-crypto.service.spec.ts b/apps/desktop/src/platform/services/electron-crypto.service.spec.ts index 04adfcac70..3d9171b52e 100644 --- a/apps/desktop/src/platform/services/electron-crypto.service.spec.ts +++ b/apps/desktop/src/platform/services/electron-crypto.service.spec.ts @@ -1,6 +1,7 @@ import { FakeStateProvider } from "@bitwarden/common/../spec/fake-state-provider"; import { mock } from "jest-mock-extended"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; @@ -30,6 +31,7 @@ describe("electronCryptoService", () => { const platformUtilService = mock(); const logService = mock(); const stateService = mock(); + let masterPasswordService: FakeMasterPasswordService; let accountService: FakeAccountService; let stateProvider: FakeStateProvider; const biometricStateService = mock(); @@ -38,9 +40,11 @@ describe("electronCryptoService", () => { beforeEach(() => { accountService = mockAccountServiceWith("userId" as UserId); + masterPasswordService = new FakeMasterPasswordService(); stateProvider = new FakeStateProvider(accountService); sut = new ElectronCryptoService( + masterPasswordService, keyGenerationService, cryptoFunctionService, encryptService, diff --git a/apps/desktop/src/platform/services/electron-crypto.service.ts b/apps/desktop/src/platform/services/electron-crypto.service.ts index 6b9327a9c4..d113a18200 100644 --- a/apps/desktop/src/platform/services/electron-crypto.service.ts +++ b/apps/desktop/src/platform/services/electron-crypto.service.ts @@ -1,6 +1,7 @@ import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service"; @@ -20,6 +21,7 @@ import { UserKey, MasterKey } from "@bitwarden/common/types/key"; export class ElectronCryptoService extends CryptoService { constructor( + masterPasswordService: InternalMasterPasswordServiceAbstraction, keyGenerationService: KeyGenerationService, cryptoFunctionService: CryptoFunctionService, encryptService: EncryptService, @@ -31,6 +33,7 @@ export class ElectronCryptoService extends CryptoService { private biometricStateService: BiometricStateService, ) { super( + masterPasswordService, keyGenerationService, cryptoFunctionService, encryptService, @@ -159,12 +162,16 @@ export class ElectronCryptoService extends CryptoService { const oldBiometricKey = await this.stateService.getCryptoMasterKeyBiometric({ userId }); // decrypt const masterKey = new SymmetricCryptoKey(Utils.fromB64ToArray(oldBiometricKey)) as MasterKey; - let encUserKey = await this.stateService.getEncryptedCryptoSymmetricKey(); - encUserKey = encUserKey ?? (await this.stateService.getMasterKeyEncryptedUserKey()); + userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id; + const encUserKeyPrim = await this.stateService.getEncryptedCryptoSymmetricKey(); + const encUserKey = + encUserKeyPrim != null + ? new EncString(encUserKeyPrim) + : await this.masterPasswordService.getMasterKeyEncryptedUserKey(userId); if (!encUserKey) { throw new Error("No user key found during biometric migration"); } - const userKey = await this.decryptUserKeyWithMasterKey(masterKey, new EncString(encUserKey)); + const userKey = await this.decryptUserKeyWithMasterKey(masterKey, encUserKey); // migrate await this.storeBiometricKey(userKey, userId); await this.stateService.setCryptoMasterKeyBiometric(null, { userId }); diff --git a/apps/desktop/src/services/native-messaging.service.ts b/apps/desktop/src/services/native-messaging.service.ts index 148e4f1e89..01d9476977 100644 --- a/apps/desktop/src/services/native-messaging.service.ts +++ b/apps/desktop/src/services/native-messaging.service.ts @@ -1,6 +1,7 @@ import { Injectable, NgZone } from "@angular/core"; import { firstValueFrom } from "rxjs"; +import { MasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -30,6 +31,7 @@ export class NativeMessagingService { private sharedSecrets = new Map(); constructor( + private masterPasswordService: MasterPasswordServiceAbstraction, private cryptoFunctionService: CryptoFunctionService, private cryptoService: CryptoService, private platformUtilService: PlatformUtilsService, @@ -162,7 +164,9 @@ export class NativeMessagingService { KeySuffixOptions.Biometric, message.userId, ); - const masterKey = await this.cryptoService.getMasterKey(message.userId); + const masterKey = await firstValueFrom( + this.masterPasswordService.masterKey$(message.userId as UserId), + ); if (userKey != null) { // we send the master key still for backwards compatibility diff --git a/apps/desktop/src/vault/app/vault/add-edit.component.ts b/apps/desktop/src/vault/app/vault/add-edit.component.ts index b89beebaa6..86e0b881ee 100644 --- a/apps/desktop/src/vault/app/vault/add-edit.component.ts +++ b/apps/desktop/src/vault/app/vault/add-edit.component.ts @@ -75,6 +75,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnChanges, async ngOnInit() { await super.ngOnInit(); + await this.load(); this.broadcasterService.subscribe(BroadcasterSubscriptionId, async (message: any) => { this.ngZone.run(() => { switch (message.command) { diff --git a/apps/desktop/src/vault/app/vault/vault.component.ts b/apps/desktop/src/vault/app/vault/vault.component.ts index e8aabbb20f..208bbc70f0 100644 --- a/apps/desktop/src/vault/app/vault/vault.component.ts +++ b/apps/desktop/src/vault/app/vault/vault.component.ts @@ -8,7 +8,7 @@ import { ViewContainerRef, } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; +import { firstValueFrom, Subject, takeUntil } from "rxjs"; import { first } from "rxjs/operators"; import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; @@ -16,13 +16,13 @@ import { ModalService } from "@bitwarden/angular/services/modal.service"; import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EventType } from "@bitwarden/common/enums"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.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 { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -32,6 +32,7 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { DialogService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; +import { AuthRequestServiceAbstraction } from "../../../../../../libs/auth/src/common/abstractions"; import { SearchBarService } from "../../../app/layout/search/search-bar.service"; import { GeneratorComponent } from "../../../app/tools/generator.component"; import { invokeMenu, RendererMenuItem } from "../../../utils"; @@ -102,11 +103,12 @@ export class VaultComponent implements OnInit, OnDestroy { private eventCollectionService: EventCollectionService, private totpService: TotpService, private passwordRepromptService: PasswordRepromptService, - private stateService: StateService, private searchBarService: SearchBarService, private apiService: ApiService, private dialogService: DialogService, private billingAccountProfileStateService: BillingAccountProfileStateService, + private authRequestService: AuthRequestServiceAbstraction, + private accountService: AccountService, ) {} async ngOnInit() { @@ -224,7 +226,8 @@ export class VaultComponent implements OnInit, OnDestroy { this.searchBarService.setEnabled(true); this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault")); - const approveLoginRequests = await this.stateService.getApproveLoginRequests(); + const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; + const approveLoginRequests = await this.authRequestService.getAcceptAuthRequests(userId); if (approveLoginRequests) { const authRequest = await this.apiService.getLastAuthRequest(); if (authRequest != null) { diff --git a/apps/web/config/base.json b/apps/web/config/base.json index a377298c63..5dc03a4633 100644 --- a/apps/web/config/base.json +++ b/apps/web/config/base.json @@ -11,7 +11,6 @@ "allowedHosts": "auto" }, "flags": { - "secretsManager": false, "showPasswordless": false, "enableCipherKeyEncryption": false } diff --git a/apps/web/config/cloud.json b/apps/web/config/cloud.json index 6e5c65af1d..3faa292692 100644 --- a/apps/web/config/cloud.json +++ b/apps/web/config/cloud.json @@ -17,7 +17,6 @@ "proxyNotifications": "https://notifications.bitwarden.com" }, "flags": { - "secretsManager": true, "showPasswordless": true, "enableCipherKeyEncryption": false } diff --git a/apps/web/config/development.json b/apps/web/config/development.json index 97742aee3d..44391a7450 100644 --- a/apps/web/config/development.json +++ b/apps/web/config/development.json @@ -20,7 +20,6 @@ } ], "flags": { - "secretsManager": true, "showPasswordless": true, "enableCipherKeyEncryption": false }, diff --git a/apps/web/config/euprd.json b/apps/web/config/euprd.json index 4b6c9fa909..72f0c1857d 100644 --- a/apps/web/config/euprd.json +++ b/apps/web/config/euprd.json @@ -11,7 +11,6 @@ "buttonAction": "https://www.paypal.com/cgi-bin/webscr" }, "flags": { - "secretsManager": true, "showPasswordless": true, "enableCipherKeyEncryption": false } diff --git a/apps/web/config/euqa.json b/apps/web/config/euqa.json index 70caf3db62..5f74eb8829 100644 --- a/apps/web/config/euqa.json +++ b/apps/web/config/euqa.json @@ -21,7 +21,6 @@ } ], "flags": { - "secretsManager": true, "showPasswordless": true } } diff --git a/apps/web/config/qa.json b/apps/web/config/qa.json index 0ce5f3dc7f..ac36b10784 100644 --- a/apps/web/config/qa.json +++ b/apps/web/config/qa.json @@ -27,7 +27,6 @@ } ], "flags": { - "secretsManager": true, "showPasswordless": true, "enableCipherKeyEncryption": false } diff --git a/apps/web/config/selfhosted.json b/apps/web/config/selfhosted.json index e16df20ad5..7e916a1116 100644 --- a/apps/web/config/selfhosted.json +++ b/apps/web/config/selfhosted.json @@ -7,7 +7,6 @@ "port": 8081 }, "flags": { - "secretsManager": true, "showPasswordless": true, "enableCipherKeyEncryption": false } diff --git a/apps/web/config/usdev.json b/apps/web/config/usdev.json index 9b794d896d..af96a38c6a 100644 --- a/apps/web/config/usdev.json +++ b/apps/web/config/usdev.json @@ -5,7 +5,6 @@ "scim": "https://scim.usdev.bitwarden.pw" }, "flags": { - "secretsManager": true, "showPasswordless": true } } diff --git a/apps/web/package.json b/apps/web/package.json index 5b049dcb9d..99828bb543 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/web-vault", - "version": "2024.3.1", + "version": "2024.4.0", "scripts": { "build:oss": "webpack", "build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js", @@ -8,7 +8,7 @@ "build:bit:watch": "webpack serve -c ../../bitwarden_license/bit-web/webpack.config.js", "build:bit:dev": "cross-env ENV=development npm run build:bit", "build:bit:dev:analyze": "cross-env LOGGING=false webpack -c ../../bitwarden_license/bit-web/webpack.config.js --profile --json > stats.json && npx webpack-bundle-analyzer stats.json build/", - "build:bit:dev:watch": "cross-env ENV=development npm run build:bit:watch", + "build:bit:dev:watch": "cross-env ENV=development NODE_OPTIONS=\"--max-old-space-size=8192\" npm run build:bit:watch", "build:bit:qa": "cross-env NODE_ENV=production ENV=qa npm run build:bit", "build:bit:euprd": "cross-env NODE_ENV=production ENV=euprd npm run build:bit", "build:bit:euqa": "cross-env NODE_ENV=production ENV=euqa npm run build:bit", diff --git a/apps/web/src/app/admin-console/common/base.people.component.ts b/apps/web/src/app/admin-console/common/base.people.component.ts index 0a1f4338ff..fbb9faf569 100644 --- a/apps/web/src/app/admin-console/common/base.people.component.ts +++ b/apps/web/src/app/admin-console/common/base.people.component.ts @@ -1,5 +1,5 @@ -import { Directive, ViewChild, ViewContainerRef } from "@angular/core"; -import { firstValueFrom } from "rxjs"; +import { Directive, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; +import { BehaviorSubject, Subject, firstValueFrom, from, switchMap, takeUntil } from "rxjs"; import { SearchPipe } from "@bitwarden/angular/pipes/search.pipe"; import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; @@ -32,8 +32,10 @@ const MaxCheckedCount = 500; @Directive() export abstract class BasePeopleComponent< - UserType extends ProviderUserUserDetailsResponse | OrganizationUserView, -> { + UserType extends ProviderUserUserDetailsResponse | OrganizationUserView, + > + implements OnInit, OnDestroy +{ @ViewChild("confirmTemplate", { read: ViewContainerRef, static: true }) confirmModalRef: ViewContainerRef; @@ -88,7 +90,6 @@ export abstract class BasePeopleComponent< status: StatusType; users: UserType[] = []; pagedUsers: UserType[] = []; - searchText: string; actionPromise: Promise; protected allUsers: UserType[] = []; @@ -97,7 +98,19 @@ export abstract class BasePeopleComponent< protected didScroll = false; protected pageSize = 100; + protected destroy$ = new Subject(); + private pagedUsersCount = 0; + private _searchText$ = new BehaviorSubject(""); + private isSearching: boolean = false; + + get searchText() { + return this._searchText$.value; + } + + set searchText(value: string) { + this._searchText$.next(value); + } constructor( protected apiService: ApiService, @@ -122,6 +135,22 @@ export abstract class BasePeopleComponent< abstract reinviteUser(id: string): Promise; abstract confirmUser(user: UserType, publicKey: Uint8Array): Promise; + ngOnInit(): void { + this._searchText$ + .pipe( + switchMap((searchText) => from(this.searchService.isSearchable(searchText))), + takeUntil(this.destroy$), + ) + .subscribe((isSearchable) => { + this.isSearching = isSearchable; + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + async load() { const response = await this.getUsers(); this.statusMap.clear(); @@ -390,12 +419,8 @@ export abstract class BasePeopleComponent< } } - isSearching() { - return this.searchService.isSearchable(this.searchText); - } - isPaging() { - const searching = this.isSearching(); + 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 diff --git a/apps/web/src/app/admin-console/organizations/create/organization-information.component.html b/apps/web/src/app/admin-console/organizations/create/organization-information.component.html index 6029cfd833..e0a8006081 100644 --- a/apps/web/src/app/admin-console/organizations/create/organization-information.component.html +++ b/apps/web/src/app/admin-console/organizations/create/organization-information.component.html @@ -20,19 +20,4 @@

-
- - {{ "accountOwnedBusiness" | i18n }} -
- - {{ "businessName" | i18n }} - - -
-
diff --git a/apps/web/src/app/admin-console/organizations/create/organization-information.component.ts b/apps/web/src/app/admin-console/organizations/create/organization-information.component.ts index 99cb3102aa..602ad82972 100644 --- a/apps/web/src/app/admin-console/organizations/create/organization-information.component.ts +++ b/apps/web/src/app/admin-console/organizations/create/organization-information.component.ts @@ -1,15 +1,32 @@ -import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; import { UntypedFormGroup } from "@angular/forms"; +import { firstValueFrom } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @Component({ selector: "app-org-info", templateUrl: "organization-information.component.html", }) -export class OrganizationInformationComponent { +export class OrganizationInformationComponent implements OnInit { @Input() nameOnly = false; @Input() createOrganization = true; @Input() isProvider = false; @Input() acceptingSponsorship = false; @Input() formGroup: UntypedFormGroup; @Output() changedBusinessOwned = new EventEmitter(); + + constructor(private accountService: AccountService) {} + + async ngOnInit(): Promise { + if (this.formGroup.controls.billingEmail.value) { + return; + } + + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + + if (activeAccount?.email) { + this.formGroup.controls.billingEmail.setValue(activeAccount.email); + } + } } diff --git a/apps/web/src/app/admin-console/organizations/manage/groups.component.ts b/apps/web/src/app/admin-console/organizations/manage/groups.component.ts index a41d57f874..9ff596181e 100644 --- a/apps/web/src/app/admin-console/organizations/manage/groups.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/groups.component.ts @@ -91,15 +91,16 @@ export class GroupsComponent implements OnInit, OnDestroy { private pagedGroupsCount = 0; private pagedGroups: GroupDetailsRow[]; private searchedGroups: GroupDetailsRow[]; - private _searchText: string; + private _searchText$ = new BehaviorSubject(""); private destroy$ = new Subject(); private refreshGroups$ = new BehaviorSubject(null); + private isSearching: boolean = false; get searchText() { - return this._searchText; + return this._searchText$.value; } set searchText(value: string) { - this._searchText = value; + this._searchText$.next(value); // Manually update as we are not using the search pipe in the template this.updateSearchedGroups(); } @@ -114,7 +115,7 @@ export class GroupsComponent implements OnInit, OnDestroy { if (this.isPaging()) { return this.pagedGroups; } - if (this.isSearching()) { + if (this.isSearching) { return this.searchedGroups; } return this.groups; @@ -180,6 +181,15 @@ export class GroupsComponent implements OnInit, OnDestroy { takeUntil(this.destroy$), ) .subscribe(); + + this._searchText$ + .pipe( + switchMap((searchText) => this.searchService.isSearchable(searchText)), + takeUntil(this.destroy$), + ) + .subscribe((isSearchable) => { + this.isSearching = isSearchable; + }); } ngOnDestroy() { @@ -297,10 +307,6 @@ export class GroupsComponent implements OnInit, OnDestroy { this.loadMore(); } - isSearching() { - return this.searchService.isSearchable(this.searchText); - } - check(groupRow: GroupDetailsRow) { groupRow.checked = !groupRow.checked; } @@ -310,7 +316,7 @@ export class GroupsComponent implements OnInit, OnDestroy { } isPaging() { - const searching = this.isSearching(); + const searching = this.isSearching; if (searching && this.didScroll) { this.resetPaging(); } @@ -340,7 +346,7 @@ export class GroupsComponent implements OnInit, OnDestroy { } private updateSearchedGroups() { - if (this.searchService.isSearchable(this.searchText)) { + if (this.isSearching) { // Making use of the pipe in the component as we need know which groups where filtered this.searchedGroups = this.searchPipe.transform( this.groups, diff --git a/apps/web/src/app/admin-console/organizations/members/people.component.ts b/apps/web/src/app/admin-console/organizations/members/people.component.ts index 8a303ddfe5..6b632dce38 100644 --- a/apps/web/src/app/admin-console/organizations/members/people.component.ts +++ b/apps/web/src/app/admin-console/organizations/members/people.component.ts @@ -1,4 +1,4 @@ -import { Component, OnDestroy, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; +import { Component, ViewChild, ViewContainerRef } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { combineLatest, @@ -9,7 +9,6 @@ import { map, Observable, shareReplay, - Subject, switchMap, takeUntil, } from "rxjs"; @@ -52,7 +51,6 @@ import { Collection } from "@bitwarden/common/vault/models/domain/collection"; import { CollectionDetailsResponse } from "@bitwarden/common/vault/models/response/collection.response"; import { DialogService, SimpleDialogOptions } from "@bitwarden/components"; -import { flagEnabled } from "../../../../utils/flags"; import { openEntityEventsDialog } from "../../../admin-console/organizations/manage/entity-events.component"; import { BasePeopleComponent } from "../../common/base.people.component"; import { GroupService } from "../core"; @@ -74,10 +72,7 @@ import { ResetPasswordComponent } from "./components/reset-password.component"; selector: "app-org-people", templateUrl: "people.component.html", }) -export class PeopleComponent - extends BasePeopleComponent - implements OnInit, OnDestroy -{ +export class PeopleComponent extends BasePeopleComponent { @ViewChild("groupsTemplate", { read: ViewContainerRef, static: true }) groupsModalRef: ViewContainerRef; @ViewChild("confirmTemplate", { read: ViewContainerRef, static: true }) @@ -100,7 +95,6 @@ export class PeopleComponent orgResetPasswordPolicyEnabled = false; protected canUseSecretsManager$: Observable; - private destroy$ = new Subject(); constructor( apiService: ApiService, @@ -148,9 +142,7 @@ export class PeopleComponent shareReplay({ refCount: true, bufferSize: 1 }), ); - this.canUseSecretsManager$ = organization$.pipe( - map((org) => org.useSecretsManager && flagEnabled("secretsManager")), - ); + this.canUseSecretsManager$ = organization$.pipe(map((org) => org.useSecretsManager)); const policies$ = organization$.pipe( switchMap((organization) => { @@ -213,8 +205,7 @@ export class PeopleComponent } ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); + super.ngOnDestroy(); } async load() { diff --git a/apps/web/src/app/admin-console/organizations/settings/account.component.html b/apps/web/src/app/admin-console/organizations/settings/account.component.html index 7035b976ca..082fe7eb80 100644 --- a/apps/web/src/app/admin-console/organizations/settings/account.component.html +++ b/apps/web/src/app/admin-console/organizations/settings/account.component.html @@ -20,10 +20,6 @@ {{ "billingEmail" | i18n }} - - {{ "businessName" | i18n }} - -
diff --git a/apps/web/src/app/admin-console/organizations/settings/account.component.ts b/apps/web/src/app/admin-console/organizations/settings/account.component.ts index b218e680e3..1ce05f7a30 100644 --- a/apps/web/src/app/admin-console/organizations/settings/account.component.ts +++ b/apps/web/src/app/admin-console/organizations/settings/account.component.ts @@ -65,10 +65,6 @@ export class AccountComponent { { value: "", disabled: true }, { validators: [Validators.required, Validators.email, Validators.maxLength(256)] }, ), - businessName: this.formBuilder.control( - { value: "", disabled: true }, - { validators: [Validators.maxLength(50)] }, - ), }); protected collectionManagementFormGroup = this.formBuilder.group({ @@ -124,7 +120,6 @@ export class AccountComponent { // Update disabled states - reactive forms prefers not using disabled attribute if (!this.selfHosted) { this.formGroup.get("orgName").enable(); - this.formGroup.get("businessName").enable(); this.collectionManagementFormGroup.get("limitCollectionCreationDeletion").enable(); this.collectionManagementFormGroup.get("allowAdminAccessToAllCollectionItems").enable(); } @@ -143,7 +138,6 @@ export class AccountComponent { this.formGroup.patchValue({ orgName: this.org.name, billingEmail: this.org.billingEmail, - businessName: this.org.businessName, }); this.collectionManagementFormGroup.patchValue({ limitCollectionCreationDeletion: this.org.limitCollectionCreationDeletion, @@ -168,7 +162,6 @@ export class AccountComponent { const request = new OrganizationUpdateRequest(); request.name = this.formGroup.value.orgName; - request.businessName = this.formGroup.value.businessName; request.billingEmail = this.formGroup.value.billingEmail; // Backfill pub/priv key if necessary diff --git a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.html b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.html index 16a24781df..f4c9c840ef 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.html +++ b/apps/web/src/app/admin-console/organizations/shared/components/access-selector/access-selector.component.html @@ -22,7 +22,7 @@ - + {{ selectorLabelText }}
@@ -139,7 +139,7 @@ - - - - diff --git a/apps/web/src/app/admin-console/organizations/users/enroll-master-password-reset.component.ts b/apps/web/src/app/admin-console/organizations/users/enroll-master-password-reset.component.ts index 4cbdbf3864..b228a4d135 100644 --- a/apps/web/src/app/admin-console/organizations/users/enroll-master-password-reset.component.ts +++ b/apps/web/src/app/admin-console/organizations/users/enroll-master-password-reset.component.ts @@ -1,12 +1,7 @@ -import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; -import { Component, Inject } from "@angular/core"; -import { FormControl, FormGroup, Validators } from "@angular/forms"; - +import { UserVerificationDialogComponent } from "@bitwarden/auth/angular"; import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service"; import { OrganizationUserResetPasswordEnrollmentRequest } from "@bitwarden/common/admin-console/abstractions/organization-user/requests"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; -import { Verification } from "@bitwarden/common/auth/types/verification"; 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"; @@ -19,63 +14,58 @@ interface EnrollMasterPasswordResetData { organization: Organization; } -@Component({ - selector: "app-enroll-master-password-reset", - templateUrl: "enroll-master-password-reset.component.html", -}) export class EnrollMasterPasswordReset { - protected organization: Organization; + constructor() {} - protected formGroup = new FormGroup({ - verification: new FormControl(null, Validators.required), - }); - - constructor( - private dialogRef: DialogRef, - @Inject(DIALOG_DATA) protected data: EnrollMasterPasswordResetData, - private resetPasswordService: OrganizationUserResetPasswordService, - private userVerificationService: UserVerificationService, - private platformUtilsService: PlatformUtilsService, - private i18nService: I18nService, - private syncService: SyncService, - private logService: LogService, - private organizationUserService: OrganizationUserService, + static async open( + dialogService: DialogService, + data: EnrollMasterPasswordResetData, + resetPasswordService: OrganizationUserResetPasswordService, + organizationUserService: OrganizationUserService, + platformUtilsService: PlatformUtilsService, + i18nService: I18nService, + syncService: SyncService, + logService: LogService, ) { - this.organization = data.organization; - } + const result = await UserVerificationDialogComponent.open(dialogService, { + title: "enrollAccountRecovery", + calloutOptions: { + text: "resetPasswordEnrollmentWarning", + type: "warning", + }, + }); - submit = async () => { - try { - await this.userVerificationService - .buildRequest( - this.formGroup.value.verification, - OrganizationUserResetPasswordEnrollmentRequest, - ) - .then(async (request) => { - // Create request and execute enrollment - request.resetPasswordKey = await this.resetPasswordService.buildRecoveryKey( - this.organization.id, - ); - await this.organizationUserService.putOrganizationUserResetPasswordEnrollment( - this.organization.id, - this.organization.userId, - request, - ); - - await this.syncService.fullSync(true); - }); - this.platformUtilsService.showToast( - "success", - null, - this.i18nService.t("enrollPasswordResetSuccess"), - ); - this.dialogRef.close(); - } catch (e) { - this.logService.error(e); + // Handle the result of the dialog based on user action and verification success + if (result.userAction === "cancel") { + return; } - }; - static open(dialogService: DialogService, data: EnrollMasterPasswordResetData) { - return dialogService.open(EnrollMasterPasswordReset, { data }); + // User confirmed the dialog so check verification success + if (!result.verificationSuccess) { + // verification failed + return; + } + + // Verification succeeded + try { + // This object is missing most of the properties in the + // `OrganizationUserResetPasswordEnrollmentRequest()`, but those + // properties don't carry over to the server model anyway and are + // never used by this flow. + const request = new OrganizationUserResetPasswordEnrollmentRequest(); + request.resetPasswordKey = await resetPasswordService.buildRecoveryKey(data.organization.id); + + await organizationUserService.putOrganizationUserResetPasswordEnrollment( + data.organization.id, + data.organization.userId, + request, + ); + + platformUtilsService.showToast("success", null, i18nService.t("enrollPasswordResetSuccess")); + + await syncService.fullSync(true); + } catch (e) { + logService.error(e); + } } } diff --git a/apps/web/src/app/admin-console/organizations/users/organization-user.module.ts b/apps/web/src/app/admin-console/organizations/users/organization-user.module.ts deleted file mode 100644 index 30e2b5abe7..0000000000 --- a/apps/web/src/app/admin-console/organizations/users/organization-user.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ScrollingModule } from "@angular/cdk/scrolling"; -import { NgModule } from "@angular/core"; - -import { UserVerificationModule } from "../../../auth/shared/components/user-verification"; -import { LooseComponentsModule, SharedModule } from "../../../shared"; - -import { EnrollMasterPasswordReset } from "./enroll-master-password-reset.component"; - -@NgModule({ - imports: [SharedModule, ScrollingModule, LooseComponentsModule, UserVerificationModule], - declarations: [EnrollMasterPasswordReset], - exports: [EnrollMasterPasswordReset], -}) -export class OrganizationUserModule {} diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 628875f04a..7a3b34969a 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -281,7 +281,7 @@ export class AppComponent implements OnDestroy, OnInit { await this.stateEventRunnerService.handleEvent("logout", userId as UserId); - this.searchService.clearIndex(); + await this.searchService.clearIndex(); this.authService.logOut(async () => { if (expired) { this.platformUtilsService.showToast( 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 09c7bf9ace..0997f18864 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 @@ -2,6 +2,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject } from "rxjs"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; @@ -9,7 +10,6 @@ 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"; @@ -22,6 +22,10 @@ import { Folder } from "@bitwarden/common/vault/models/domain/folder"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; +import { + FakeAccountService, + mockAccountServiceWith, +} from "../../../../../../libs/common/spec/fake-account-service"; import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service"; import { StateService } from "../../core"; import { EmergencyAccessService } from "../emergency-access"; @@ -46,8 +50,10 @@ describe("KeyRotationService", () => { const mockUserId = Utils.newGuid() as UserId; const mockAccountService: FakeAccountService = mockAccountServiceWith(mockUserId); + let mockMasterPasswordService: FakeMasterPasswordService = new FakeMasterPasswordService(); beforeAll(() => { + mockMasterPasswordService = new FakeMasterPasswordService(); mockApiService = mock(); mockCipherService = mock(); mockFolderService = mock(); @@ -61,6 +67,7 @@ describe("KeyRotationService", () => { mockConfigService = mock(); keyRotationService = new UserKeyRotationService( + mockMasterPasswordService, mockApiService, mockCipherService, mockFolderService, @@ -174,7 +181,10 @@ describe("KeyRotationService", () => { it("saves the master key in state after creation", async () => { await keyRotationService.rotateUserKeyAndEncryptedData("mockMasterPassword"); - expect(mockCryptoService.setMasterKey).toHaveBeenCalledWith("mockMasterKey" as any); + expect(mockMasterPasswordService.mock.setMasterKey).toHaveBeenCalledWith( + "mockMasterKey" as any, + mockUserId, + ); }); it("uses legacy rotation if feature flag is off", async () => { 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 03bc604b4d..f5812d341a 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 @@ -3,6 +3,7 @@ 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 { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -25,6 +26,7 @@ import { UserKeyRotationApiService } from "./user-key-rotation-api.service"; @Injectable() export class UserKeyRotationService { constructor( + private masterPasswordService: InternalMasterPasswordServiceAbstraction, private apiService: UserKeyRotationApiService, private cipherService: CipherService, private folderService: FolderService, @@ -61,7 +63,8 @@ export class UserKeyRotationService { } // Set master key again in case it was lost (could be lost on refresh) - await this.cryptoService.setMasterKey(masterKey); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setMasterKey(masterKey, userId); const [newUserKey, newEncUserKey] = await this.cryptoService.makeUserKey(masterKey); if (!newUserKey || !newEncUserKey) { diff --git a/apps/web/src/app/auth/lock.component.ts b/apps/web/src/app/auth/lock.component.ts index a1d4724396..021bf0f9df 100644 --- a/apps/web/src/app/auth/lock.component.ts +++ b/apps/web/src/app/auth/lock.component.ts @@ -1,80 +1,12 @@ -import { Component, NgZone } from "@angular/core"; -import { Router } from "@angular/router"; +import { Component } from "@angular/core"; import { LockComponent as BaseLockComponent } from "@bitwarden/angular/auth/components/lock.component"; -import { PinCryptoServiceAbstraction } from "@bitwarden/auth/common"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service"; -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"; -import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -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 { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service"; -import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; -import { DialogService } from "@bitwarden/components"; @Component({ selector: "app-lock", templateUrl: "lock.component.html", }) export class LockComponent extends BaseLockComponent { - constructor( - router: Router, - i18nService: I18nService, - platformUtilsService: PlatformUtilsService, - messagingService: MessagingService, - cryptoService: CryptoService, - vaultTimeoutService: VaultTimeoutService, - vaultTimeoutSettingsService: VaultTimeoutSettingsService, - environmentService: EnvironmentService, - stateService: StateService, - apiService: ApiService, - logService: LogService, - ngZone: NgZone, - policyApiService: PolicyApiServiceAbstraction, - policyService: InternalPolicyService, - passwordStrengthService: PasswordStrengthServiceAbstraction, - dialogService: DialogService, - deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction, - userVerificationService: UserVerificationService, - pinCryptoService: PinCryptoServiceAbstraction, - biometricStateService: BiometricStateService, - accountService: AccountService, - ) { - super( - router, - i18nService, - platformUtilsService, - messagingService, - cryptoService, - vaultTimeoutService, - vaultTimeoutSettingsService, - environmentService, - stateService, - apiService, - logService, - ngZone, - policyApiService, - policyService, - passwordStrengthService, - dialogService, - deviceTrustCryptoService, - userVerificationService, - pinCryptoService, - biometricStateService, - accountService, - ); - } - async ngOnInit() { await super.ngOnInit(); this.onSuccessfulSubmit = async () => { diff --git a/apps/web/src/app/auth/sso.component.ts b/apps/web/src/app/auth/sso.component.ts index cdd979aa89..e120b2749f 100644 --- a/apps/web/src/app/auth/sso.component.ts +++ b/apps/web/src/app/auth/sso.component.ts @@ -10,6 +10,8 @@ import { import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrgDomainApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization-domain/org-domain-api.service.abstraction"; import { OrganizationDomainSsoDetailsResponse } from "@bitwarden/common/admin-console/abstractions/organization-domain/responses/organization-domain-sso-details.response"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { HttpStatusCode } from "@bitwarden/common/enums"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; @@ -46,6 +48,8 @@ export class SsoComponent extends BaseSsoComponent { private validationService: ValidationService, userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, configService: ConfigService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, + accountService: AccountService, ) { super( ssoLoginService, @@ -62,6 +66,8 @@ export class SsoComponent extends BaseSsoComponent { logService, userDecryptionOptionsService, configService, + masterPasswordService, + accountService, ); this.redirectUri = window.location.origin + "/sso-connector.html"; this.clientId = "web"; diff --git a/apps/web/src/app/auth/two-factor.component.ts b/apps/web/src/app/auth/two-factor.component.ts index 65bf1dba58..eed84b91f1 100644 --- a/apps/web/src/app/auth/two-factor.component.ts +++ b/apps/web/src/app/auth/two-factor.component.ts @@ -10,6 +10,8 @@ import { UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; @@ -50,6 +52,8 @@ export class TwoFactorComponent extends BaseTwoFactorComponent implements OnDest userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, ssoLoginService: SsoLoginServiceAbstraction, configService: ConfigService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, + accountService: AccountService, @Inject(WINDOW) protected win: Window, ) { super( @@ -69,6 +73,8 @@ export class TwoFactorComponent extends BaseTwoFactorComponent implements OnDest userDecryptionOptionsService, ssoLoginService, configService, + masterPasswordService, + accountService, ); this.onSuccessfulLoginNavigate = this.goAfterLogIn; } diff --git a/apps/web/src/app/billing/organizations/organization-plans.component.ts b/apps/web/src/app/billing/organizations/organization-plans.component.ts index 1242018673..f2fb296522 100644 --- a/apps/web/src/app/billing/organizations/organization-plans.component.ts +++ b/apps/web/src/app/billing/organizations/organization-plans.component.ts @@ -47,7 +47,11 @@ interface OnSuccessArgs { organizationId: string; } -const Allowed2020PlanTypes = [ +const AllowedLegacyPlanTypes = [ + PlanType.TeamsMonthly2023, + PlanType.TeamsAnnually2023, + PlanType.EnterpriseAnnually2023, + PlanType.EnterpriseMonthly2023, PlanType.TeamsMonthly2020, PlanType.TeamsAnnually2020, PlanType.EnterpriseAnnually2020, @@ -116,7 +120,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { additionalStorage: [0, [Validators.min(0), Validators.max(99)]], additionalSeats: [0, [Validators.min(0), Validators.max(100000)]], clientOwnerEmail: ["", [Validators.email]], - businessName: [""], plan: [this.plan], product: [this.product], secretsManager: this.secretsManagerSubscription, @@ -278,7 +281,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { (!this.currentPlan || this.currentPlan.upgradeSortOrder < plan.upgradeSortOrder) && (!this.hasProvider || plan.product !== ProductType.TeamsStarter) && ((!this.isProviderQualifiedFor2020Plan() && this.planIsEnabled(plan)) || - (this.isProviderQualifiedFor2020Plan() && Allowed2020PlanTypes.includes(plan.type))), + (this.isProviderQualifiedFor2020Plan() && AllowedLegacyPlanTypes.includes(plan.type))), ); result.sort((planA, planB) => planA.displaySortOrder - planB.displaySortOrder); @@ -293,7 +296,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { (plan) => plan.product === selectedProductType && ((!this.isProviderQualifiedFor2020Plan() && this.planIsEnabled(plan)) || - (this.isProviderQualifiedFor2020Plan() && Allowed2020PlanTypes.includes(plan.type))), + (this.isProviderQualifiedFor2020Plan() && AllowedLegacyPlanTypes.includes(plan.type))), ) || []; result.sort((planA, planB) => planA.displaySortOrder - planB.displaySortOrder); @@ -592,9 +595,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { private async updateOrganization(orgId: string) { const request = new OrganizationUpgradeRequest(); - request.businessName = this.formGroup.controls.businessOwned.value - ? this.formGroup.controls.businessName.value - : null; request.additionalSeats = this.formGroup.controls.additionalSeats.value; request.additionalStorageGb = this.formGroup.controls.additionalStorage.value; request.premiumAccessAddon = @@ -652,9 +652,6 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy { request.paymentToken = tokenResult[0]; request.paymentMethodType = tokenResult[1]; - request.businessName = this.formGroup.controls.businessOwned.value - ? this.formGroup.controls.businessName.value - : null; request.additionalSeats = this.formGroup.controls.additionalSeats.value; request.additionalStorageGb = this.formGroup.controls.additionalStorage.value; request.premiumAccessAddon = diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts index 0810f79b8e..2173d4c0ca 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts @@ -241,6 +241,8 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy return ( this.sub.planType === PlanType.EnterpriseAnnually || this.sub.planType === PlanType.EnterpriseMonthly || + this.sub.planType === PlanType.EnterpriseAnnually2023 || + this.sub.planType === PlanType.EnterpriseMonthly2023 || this.sub.planType === PlanType.EnterpriseAnnually2020 || this.sub.planType === PlanType.EnterpriseMonthly2020 || this.sub.planType === PlanType.EnterpriseAnnually2019 || @@ -254,6 +256,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy } else if ( this.sub.planType === PlanType.FamiliesAnnually || this.sub.planType === PlanType.FamiliesAnnually2019 || + this.sub.planType === PlanType.TeamsStarter2023 || this.sub.planType === PlanType.TeamsStarter ) { if (this.isSponsoredSubscription) { diff --git a/apps/web/src/app/billing/shared/tax-info.component.html b/apps/web/src/app/billing/shared/tax-info.component.html index caf92f4189..30cca550d3 100644 --- a/apps/web/src/app/billing/shared/tax-info.component.html +++ b/apps/web/src/app/billing/shared/tax-info.component.html @@ -279,10 +279,7 @@ />
-
+
-
+
- +
-
+
diff --git a/apps/web/src/app/billing/shared/tax-info.component.ts b/apps/web/src/app/billing/shared/tax-info.component.ts index 2046cf44be..a704c86eb5 100644 --- a/apps/web/src/app/billing/shared/tax-info.component.ts +++ b/apps/web/src/app/billing/shared/tax-info.component.ts @@ -3,7 +3,7 @@ import { ActivatedRoute } from "@angular/router"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { OrganizationTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/organization-tax-info-update.request"; +import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; import { TaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/tax-info-update.request"; import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.response"; import { TaxRateResponse } from "@bitwarden/common/billing/models/response/tax-rate.response"; @@ -29,6 +29,7 @@ export class TaxInfoComponent { loading = true; organizationId: string; + providerId: string; taxInfo: TaxInfoView = { taxId: null, line1: null, @@ -61,6 +62,12 @@ export class TaxInfoComponent { ) {} async ngOnInit() { + // Provider setup + // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe + this.route.queryParams.subscribe((params) => { + this.providerId = params.providerId; + }); + // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe this.route.parent.parent.params.subscribe(async (params) => { this.organizationId = params.organizationId; @@ -126,9 +133,25 @@ export class TaxInfoComponent { } } + get showTaxIdCheckbox() { + return ( + (this.organizationId || this.providerId) && + this.taxInfo.country !== "US" && + this.countrySupportsTax(this.taxInfo.country) + ); + } + + get showTaxIdFields() { + return ( + (this.organizationId || this.providerId) && + this.taxInfo.includeTaxId && + this.countrySupportsTax(this.taxInfo.country) + ); + } + getTaxInfoRequest(): TaxInfoUpdateRequest { - if (this.organizationId) { - const request = new OrganizationTaxInfoUpdateRequest(); + if (this.organizationId || this.providerId) { + const request = new ExpandedTaxInfoUpdateRequest(); request.country = this.taxInfo.country; request.postalCode = this.taxInfo.postalCode; @@ -164,7 +187,7 @@ export class TaxInfoComponent { return this.organizationId ? this.organizationApiService.updateTaxInfo( this.organizationId, - request as OrganizationTaxInfoUpdateRequest, + request as ExpandedTaxInfoUpdateRequest, ) : this.apiService.putTaxInfo(request); } diff --git a/apps/web/src/app/core/state/state.service.ts b/apps/web/src/app/core/state/state.service.ts index 54e456d34c..1ae62d8591 100644 --- a/apps/web/src/app/core/state/state.service.ts +++ b/apps/web/src/app/core/state/state.service.ts @@ -18,7 +18,6 @@ import { StateFactory } from "@bitwarden/common/platform/factories/state-factory import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { StateService as BaseStateService } from "@bitwarden/common/platform/services/state.service"; -import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { Account } from "./account"; import { GlobalState } from "./global-state"; @@ -57,19 +56,6 @@ export class StateService extends BaseStateService { await super.addAccount(account); } - async getEncryptedCiphers(options?: StorageOptions): Promise<{ [id: string]: CipherData }> { - options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); - return await super.getEncryptedCiphers(options); - } - - async setEncryptedCiphers( - value: { [id: string]: CipherData }, - options?: StorageOptions, - ): Promise { - options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); - return await super.setEncryptedCiphers(value, options); - } - override async getLastSync(options?: StorageOptions): Promise { options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); return await super.getLastSync(options); diff --git a/apps/web/src/app/layouts/header/web-header.component.html b/apps/web/src/app/layouts/header/web-header.component.html index 514e5deebd..9346763a47 100644 --- a/apps/web/src/app/layouts/header/web-header.component.html +++ b/apps/web/src/app/layouts/header/web-header.component.html @@ -1,16 +1,20 @@ - {{ "newWebApp" | i18n }} + {{ unassignedItemsBannerService.bannerText$ | async | i18n }} {{ "releaseBlog" | i18n }}{{ "learnMore" | i18n }}
; protected selfHosted: boolean; protected hostname = location.hostname; + protected unassignedItemsBannerEnabled$ = this.configService.getFeatureFlag$( + FeatureFlag.UnassignedItemsBanner, + ); constructor( private route: ActivatedRoute, @@ -38,7 +42,8 @@ export class WebHeaderComponent { private platformUtilsService: PlatformUtilsService, private vaultTimeoutSettingsService: VaultTimeoutSettingsService, private messagingService: MessagingService, - protected webLayoutMigrationBannerService: WebLayoutMigrationBannerService, + protected unassignedItemsBannerService: UnassignedItemsBannerService, + private configService: ConfigService, ) { this.routeData$ = this.route.data.pipe( map((params) => { diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher.component.html b/apps/web/src/app/layouts/product-switcher/product-switcher.component.html index 449557b6f4..c9956f05e4 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher.component.html +++ b/apps/web/src/app/layouts/product-switcher/product-switcher.component.html @@ -1,10 +1,8 @@ - - - - + + diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher.component.ts b/apps/web/src/app/layouts/product-switcher/product-switcher.component.ts index a461785c31..eff5f08702 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher.component.ts +++ b/apps/web/src/app/layouts/product-switcher/product-switcher.component.ts @@ -1,16 +1,11 @@ import { AfterViewInit, ChangeDetectorRef, Component, Input } from "@angular/core"; import { IconButtonType } from "@bitwarden/components/src/icon-button/icon-button.component"; - -import { flagEnabled } from "../../../utils/flags"; - @Component({ selector: "product-switcher", templateUrl: "./product-switcher.component.html", }) export class ProductSwitcherComponent implements AfterViewInit { - protected isEnabled = flagEnabled("secretsManager"); - /** * Passed to the product switcher's `bitIconButton` */ diff --git a/apps/web/src/app/oss.module.ts b/apps/web/src/app/oss.module.ts index 73c03fd5dc..3f18440d23 100644 --- a/apps/web/src/app/oss.module.ts +++ b/apps/web/src/app/oss.module.ts @@ -1,6 +1,5 @@ import { NgModule } from "@angular/core"; -import { OrganizationUserModule } from "./admin-console/organizations/users/organization-user.module"; import { AuthModule } from "./auth"; import { LoginModule } from "./auth/login/login.module"; import { TrialInitiationModule } from "./auth/trial-initiation/trial-initiation.module"; @@ -16,7 +15,6 @@ import { VaultFilterModule } from "./vault/individual-vault/vault-filter/vault-f TrialInitiationModule, VaultFilterModule, OrganizationBadgeModule, - OrganizationUserModule, LoginModule, AuthModule, AccessComponent, diff --git a/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.html b/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.html index d0ffb90911..b64ce5bb00 100644 --- a/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.html +++ b/apps/web/src/app/vault/components/collection-dialog/collection-dialog.component.html @@ -65,17 +65,22 @@
- {{ "grantCollectionAccess" | i18n }} - {{ - "grantCollectionAccessMembersOnly" | i18n - }} - {{ " " + ("adminCollectionAccess" | i18n) }} + + {{ "readOnlyCollectionAccess" | i18n }} + + + {{ "grantCollectionAccess" | i18n }} + {{ + "grantCollectionAccessMembersOnly" | i18n + }} + {{ " " + ("adminCollectionAccess" | i18n) }} +
- -
-
+
+ +
diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts index b3d3112bf5..ed7b42c959 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts @@ -1,9 +1,11 @@ -import { Component, OnInit } from "@angular/core"; +import { Component, OnInit, ViewChild } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; import { first } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ProviderSetupRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-setup.request"; +import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -12,6 +14,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { ProviderKey } from "@bitwarden/common/types/key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; +import { TaxInfoComponent } from "@bitwarden/web-vault/app/billing"; @Component({ selector: "provider-setup", @@ -19,6 +22,8 @@ import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.serv }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil export class SetupComponent implements OnInit { + @ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent; + loading = true; authed = false; email: string; @@ -34,6 +39,11 @@ export class SetupComponent implements OnInit { false, ); + protected enableConsolidatedBilling$ = this.configService.getFeatureFlag$( + FeatureFlag.EnableConsolidatedBilling, + false, + ); + constructor( private router: Router, private platformUtilsService: PlatformUtilsService, @@ -102,6 +112,22 @@ export class SetupComponent implements OnInit { request.token = this.token; request.key = key; + const enableConsolidatedBilling = await firstValueFrom(this.enableConsolidatedBilling$); + + if (enableConsolidatedBilling) { + request.taxInfo = new ExpandedTaxInfoUpdateRequest(); + const taxInfoView = this.taxInfoComponent.taxInfo; + request.taxInfo.country = taxInfoView.country; + request.taxInfo.postalCode = taxInfoView.postalCode; + if (taxInfoView.includeTaxId) { + request.taxInfo.taxId = taxInfoView.taxId; + request.taxInfo.line1 = taxInfoView.line1; + request.taxInfo.line2 = taxInfoView.line2; + request.taxInfo.city = taxInfoView.city; + request.taxInfo.state = taxInfoView.state; + } + } + const provider = await this.apiService.postProviderSetup(this.providerId, request); this.platformUtilsService.showToast("success", null, this.i18nService.t("providerSetup")); await this.syncService.fullSync(true); 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 index 79dd25e891..a9f341be94 100644 --- 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 @@ -1,8 +1,8 @@ import { SelectionModel } from "@angular/cdk/collections"; -import { Component, OnInit } from "@angular/core"; +import { Component, OnDestroy, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { firstValueFrom } from "rxjs"; -import { first } from "rxjs/operators"; +import { BehaviorSubject, Subject, firstValueFrom, from } from "rxjs"; +import { first, switchMap, takeUntil } from "rxjs/operators"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; @@ -23,12 +23,22 @@ import { ManageClientOrganizationSubscriptionComponent } from "./manage-client-o }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class ManageClientOrganizationsComponent implements OnInit { +export class ManageClientOrganizationsComponent implements OnInit, OnDestroy { providerId: string; loading = true; manageOrganizations = false; + private destroy$ = new Subject(); + private _searchText$ = new BehaviorSubject(""); + private isSearching: boolean = false; + + get searchText() { + return this._searchText$.value; + } + set searchText(search: string) { + this._searchText$.value; + this.selection.clear(); this.dataSource.filter = search; } @@ -67,6 +77,20 @@ export class ManageClientOrganizationsComponent implements OnInit { this.searchText = qParams.search; }); }); + + this._searchText$ + .pipe( + switchMap((searchText) => from(this.searchService.isSearchable(searchText))), + takeUntil(this.destroy$), + ) + .subscribe((isSearchable) => { + this.isSearching = isSearchable; + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); } async load() { @@ -80,7 +104,7 @@ export class ManageClientOrganizationsComponent implements OnInit { } isPaging() { - const searching = this.isSearching(); + 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 @@ -89,10 +113,6 @@ export class ManageClientOrganizationsComponent implements OnInit { return !searching && this.clients && this.clients.length > this.pageSize; } - isSearching() { - return this.searchService.isSearchable(this.searchText); - } - async resetPaging() { this.pagedClients = []; this.loadMore(); diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/overview/sm-onboarding-tasks.service.ts b/bitwarden_license/bit-web/src/app/secrets-manager/overview/sm-onboarding-tasks.service.ts index 77a218bdf8..ddca88048f 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/overview/sm-onboarding-tasks.service.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/overview/sm-onboarding-tasks.service.ts @@ -3,16 +3,21 @@ import { Observable, map } from "rxjs"; import { ActiveUserState, - KeyDefinition, SM_ONBOARDING_DISK, StateProvider, + UserKeyDefinition, } from "@bitwarden/common/platform/state"; export type SMOnboardingTasks = Record>; -const SM_ONBOARDING_TASKS_KEY = new KeyDefinition(SM_ONBOARDING_DISK, "tasks", { - deserializer: (b) => b, -}); +const SM_ONBOARDING_TASKS_KEY = new UserKeyDefinition( + SM_ONBOARDING_DISK, + "tasks", + { + deserializer: (b) => b, + clearOn: [], // Used to track tasks completed by a user, we don't want to reshow if they've locked or logged out and came back to the app + }, +); @Injectable({ providedIn: "root", diff --git a/bitwarden_license/bit-web/src/app/secrets-manager/sm-routing.module.ts b/bitwarden_license/bit-web/src/app/secrets-manager/sm-routing.module.ts index f9ddcdad78..55dc2f8b71 100644 --- a/bitwarden_license/bit-web/src/app/secrets-manager/sm-routing.module.ts +++ b/bitwarden_license/bit-web/src/app/secrets-manager/sm-routing.module.ts @@ -2,7 +2,6 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { AuthGuard } from "@bitwarden/angular/auth/guards"; -import { buildFlaggedRoute } from "@bitwarden/web-vault/app/oss-routing.module"; import { organizationEnabledGuard } from "./guards/sm-org-enabled.guard"; import { canActivateSM } from "./guards/sm.guard"; @@ -17,7 +16,7 @@ import { OrgSuspendedComponent } from "./shared/org-suspended.component"; import { TrashModule } from "./trash/trash.module"; const routes: Routes = [ - buildFlaggedRoute("secretsManager", { + { path: "", children: [ { @@ -86,7 +85,7 @@ const routes: Routes = [ ], }, ], - }), + }, ]; @NgModule({ diff --git a/libs/angular/jest.config.js b/libs/angular/jest.config.js index e294e4ff47..c8e748575c 100644 --- a/libs/angular/jest.config.js +++ b/libs/angular/jest.config.js @@ -10,7 +10,11 @@ module.exports = { displayName: "libs/angular tests", preset: "jest-preset-angular", setupFilesAfterEnv: ["/test.setup.ts"], - moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, { - prefix: "/", - }), + moduleNameMapper: pathsToModuleNameMapper( + // lets us use @bitwarden/common/spec in tests + { "@bitwarden/common/spec": ["../common/spec"], ...(compilerOptions?.paths ?? {}) }, + { + prefix: "/", + }, + ), }; diff --git a/libs/angular/src/admin-console/components/collections.component.ts b/libs/angular/src/admin-console/components/collections.component.ts index 167fe0a97f..5f8c4145cb 100644 --- a/libs/angular/src/admin-console/components/collections.component.ts +++ b/libs/angular/src/admin-console/components/collections.component.ts @@ -59,7 +59,7 @@ export class CollectionsComponent implements OnInit { } } - async submit() { + async submit(): Promise { const selectedCollectionIds = this.collections .filter((c) => { if (this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) { @@ -75,7 +75,7 @@ export class CollectionsComponent implements OnInit { this.i18nService.t("errorOccurred"), this.i18nService.t("selectOneCollection"), ); - return; + return false; } this.cipherDomain.collectionIds = selectedCollectionIds; try { @@ -83,8 +83,10 @@ export class CollectionsComponent implements OnInit { await this.formPromise; this.onSavedCollections.emit(); this.platformUtilsService.showToast("success", null, this.i18nService.t("editedItem")); + return true; } catch (e) { this.logService.error(e); + return false; } } diff --git a/libs/angular/src/auth/components/lock.component.ts b/libs/angular/src/auth/components/lock.component.ts index aa3b801ded..6602a917c9 100644 --- a/libs/angular/src/auth/components/lock.component.ts +++ b/libs/angular/src/auth/components/lock.component.ts @@ -12,6 +12,7 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti 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 { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.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"; import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request"; @@ -56,6 +57,7 @@ export class LockComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); constructor( + protected masterPasswordService: InternalMasterPasswordServiceAbstraction, protected router: Router, protected i18nService: I18nService, protected platformUtilsService: PlatformUtilsService, @@ -206,6 +208,7 @@ export class LockComponent implements OnInit, OnDestroy { } private async doUnlockWithMasterPassword() { + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; const kdf = await this.stateService.getKdfType(); const kdfConfig = await this.stateService.getKdfConfig(); @@ -215,11 +218,13 @@ export class LockComponent implements OnInit, OnDestroy { kdf, kdfConfig, ); - const storedPasswordHash = await this.cryptoService.getMasterKeyHash(); + const storedMasterKeyHash = await firstValueFrom( + this.masterPasswordService.masterKeyHash$(userId), + ); let passwordValid = false; - if (storedPasswordHash != null) { + if (storedMasterKeyHash != null) { // Offline unlock possible passwordValid = await this.cryptoService.compareAndUpdateKeyHash( this.masterPassword, @@ -244,7 +249,7 @@ export class LockComponent implements OnInit, OnDestroy { masterKey, HashPurpose.LocalAuthorization, ); - await this.cryptoService.setMasterKeyHash(localKeyHash); + await this.masterPasswordService.setMasterKeyHash(localKeyHash, userId); } catch (e) { this.logService.error(e); } finally { @@ -262,7 +267,7 @@ export class LockComponent implements OnInit, OnDestroy { } const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey); - await this.cryptoService.setMasterKey(masterKey); + await this.masterPasswordService.setMasterKey(masterKey, userId); await this.setUserKeyAndContinue(userKey, true); } @@ -292,8 +297,10 @@ export class LockComponent implements OnInit, OnDestroy { } if (this.requirePasswordChange()) { - await this.stateService.setForceSetPasswordReason( + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setForceSetPasswordReason( ForceSetPasswordReason.WeakMasterPassword, + userId, ); // 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 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 6ba94d3001..5a1180cd38 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 @@ -33,6 +33,7 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; +import { UserId } from "@bitwarden/common/types/guid"; import { CaptchaProtectedComponent } from "./captcha-protected.component"; @@ -131,6 +132,7 @@ export class LoginViaAuthRequestComponent // This also prevents it from being lost on refresh as the // login service email does not persist. this.email = await this.stateService.getEmail(); + const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; if (!this.email) { this.platformUtilsService.showToast("error", null, this.i18nService.t("userEmailMissing")); @@ -142,10 +144,10 @@ export class LoginViaAuthRequestComponent // We only allow a single admin approval request to be active at a time // so must check state to see if we have an existing one or not - const adminAuthReqStorable = await this.stateService.getAdminAuthRequest(); + const adminAuthReqStorable = await this.authRequestService.getAdminAuthRequest(userId); if (adminAuthReqStorable) { - await this.handleExistingAdminAuthRequest(adminAuthReqStorable); + await this.handleExistingAdminAuthRequest(adminAuthReqStorable, userId); } else { // No existing admin auth request; so we need to create one await this.startAuthRequestLogin(); @@ -173,7 +175,10 @@ export class LoginViaAuthRequestComponent this.destroy$.complete(); } - private async handleExistingAdminAuthRequest(adminAuthReqStorable: AdminAuthRequestStorable) { + private async handleExistingAdminAuthRequest( + adminAuthReqStorable: AdminAuthRequestStorable, + userId: UserId, + ) { // Note: on login, the SSOLoginStrategy will also call to see an existing admin auth req // has been approved and handle it if so. @@ -183,13 +188,13 @@ export class LoginViaAuthRequestComponent adminAuthReqResponse = await this.apiService.getAuthRequest(adminAuthReqStorable.id); } catch (error) { if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) { - return await this.handleExistingAdminAuthReqDeletedOrDenied(); + return await this.handleExistingAdminAuthReqDeletedOrDenied(userId); } } // Request doesn't exist anymore if (!adminAuthReqResponse) { - return await this.handleExistingAdminAuthReqDeletedOrDenied(); + return await this.handleExistingAdminAuthReqDeletedOrDenied(userId); } // Re-derive the user's fingerprint phrase @@ -203,7 +208,7 @@ export class LoginViaAuthRequestComponent // Request denied if (adminAuthReqResponse.isAnswered && !adminAuthReqResponse.requestApproved) { - return await this.handleExistingAdminAuthReqDeletedOrDenied(); + return await this.handleExistingAdminAuthReqDeletedOrDenied(userId); } // Request approved @@ -211,6 +216,7 @@ export class LoginViaAuthRequestComponent return await this.handleApprovedAdminAuthRequest( adminAuthReqResponse, adminAuthReqStorable.privateKey, + userId, ); } @@ -219,9 +225,9 @@ export class LoginViaAuthRequestComponent await this.anonymousHubService.createHubConnection(adminAuthReqStorable.id); } - private async handleExistingAdminAuthReqDeletedOrDenied() { + private async handleExistingAdminAuthReqDeletedOrDenied(userId: UserId) { // clear the admin auth request from state - await this.stateService.setAdminAuthRequest(null); + await this.authRequestService.clearAdminAuthRequest(userId); // start new auth request // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. @@ -269,7 +275,8 @@ export class LoginViaAuthRequestComponent privateKey: this.authRequestKeyPair.privateKey, }); - await this.stateService.setAdminAuthRequest(adminAuthReqStorable); + const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; + await this.authRequestService.setAdminAuthRequest(adminAuthReqStorable, userId); } else { await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock); reqResponse = await this.apiService.postAuthRequest(this.authRequest); @@ -333,9 +340,11 @@ export class LoginViaAuthRequestComponent // if user has authenticated via SSO if (this.userAuthNStatus === AuthenticationStatus.Locked) { + const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; return await this.handleApprovedAdminAuthRequest( authReqResponse, this.authRequestKeyPair.privateKey, + userId, ); } @@ -363,6 +372,7 @@ export class LoginViaAuthRequestComponent async handleApprovedAdminAuthRequest( adminAuthReqResponse: AuthRequestResponse, privateKey: ArrayBuffer, + userId: UserId, ) { // See verifyAndHandleApprovedAuthReq(...) for flow details // it's flow 2 or 3 based on presence of masterPasswordHash @@ -384,7 +394,7 @@ export class LoginViaAuthRequestComponent // clear the admin auth request from state so it cannot be used again (it's a one time use) // TODO: this should eventually be enforced via deleting this on the server once it is used - await this.stateService.setAdminAuthRequest(null); + await this.authRequestService.clearAdminAuthRequest(userId); this.platformUtilsService.showToast("success", null, this.i18nService.t("loginApproved")); diff --git a/libs/angular/src/auth/components/set-password.component.ts b/libs/angular/src/auth/components/set-password.component.ts index a7442f711b..eebf87655b 100644 --- a/libs/angular/src/auth/components/set-password.component.ts +++ b/libs/angular/src/auth/components/set-password.component.ts @@ -12,6 +12,8 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options"; import { OrganizationAutoEnrollStatusResponse } from "@bitwarden/common/admin-console/models/response/organization-auto-enroll-status.response"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request"; @@ -29,6 +31,7 @@ import { import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; +import { UserId } from "@bitwarden/common/types/guid"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { DialogService } from "@bitwarden/components"; @@ -45,11 +48,14 @@ export class SetPasswordComponent extends BaseChangePasswordComponent { resetPasswordAutoEnroll = false; onSuccessfulChangePassword: () => Promise; successRoute = "vault"; + userId: UserId; forceSetPasswordReason: ForceSetPasswordReason = ForceSetPasswordReason.None; ForceSetPasswordReason = ForceSetPasswordReason; constructor( + private accountService: AccountService, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, i18nService: I18nService, cryptoService: CryptoService, messagingService: MessagingService, @@ -88,7 +94,11 @@ export class SetPasswordComponent extends BaseChangePasswordComponent { await this.syncService.fullSync(true); this.syncLoading = false; - this.forceSetPasswordReason = await this.stateService.getForceSetPasswordReason(); + this.userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + + this.forceSetPasswordReason = await firstValueFrom( + this.masterPasswordService.forceSetPasswordReason$(this.userId), + ); this.route.queryParams .pipe( @@ -176,7 +186,6 @@ export class SetPasswordComponent extends BaseChangePasswordComponent { if (response == null) { throw new Error(this.i18nService.t("resetPasswordOrgKeysError")); } - const userId = await this.stateService.getUserId(); const publicKey = Utils.fromB64ToArray(response.publicKey); // RSA Encrypt user key with organization public key @@ -189,7 +198,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent { return this.organizationUserService.putOrganizationUserResetPasswordEnrollment( this.orgId, - userId, + this.userId, resetRequest, ); }); @@ -226,7 +235,10 @@ export class SetPasswordComponent extends BaseChangePasswordComponent { keyPair: [string, EncString] | null, ) { // Clear force set password reason to allow navigation back to vault. - await this.stateService.setForceSetPasswordReason(ForceSetPasswordReason.None); + await this.masterPasswordService.setForceSetPasswordReason( + ForceSetPasswordReason.None, + this.userId, + ); // User now has a password so update account decryption options in state const userDecryptionOpts = await firstValueFrom( @@ -237,7 +249,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent { await this.stateService.setKdfType(this.kdf); await this.stateService.setKdfConfig(this.kdfConfig); - await this.cryptoService.setMasterKey(masterKey); + await this.masterPasswordService.setMasterKey(masterKey, this.userId); await this.cryptoService.setUserKey(userKey[0]); // Set private key only for new JIT provisioned users in MP encryption orgs @@ -255,6 +267,6 @@ export class SetPasswordComponent extends BaseChangePasswordComponent { masterKey, HashPurpose.LocalAuthorization, ); - await this.cryptoService.setMasterKeyHash(localMasterKeyHash); + await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, this.userId); } } diff --git a/libs/angular/src/auth/components/sso.component.spec.ts b/libs/angular/src/auth/components/sso.component.spec.ts index c5c062d9a7..269ec51e30 100644 --- a/libs/angular/src/auth/components/sso.component.spec.ts +++ b/libs/angular/src/auth/components/sso.component.spec.ts @@ -12,10 +12,13 @@ import { UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -23,7 +26,9 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; +import { UserId } from "@bitwarden/common/types/guid"; import { SsoComponent } from "./sso.component"; // test component that extends the SsoComponent @@ -48,6 +53,7 @@ describe("SsoComponent", () => { let component: TestSsoComponent; let _component: SsoComponentProtected; let fixture: ComponentFixture; + const userId = "userId" as UserId; // Mock Services let mockLoginStrategyService: MockProxy; @@ -67,6 +73,8 @@ describe("SsoComponent", () => { let mockLogService: MockProxy; let mockUserDecryptionOptionsService: MockProxy; let mockConfigService: MockProxy; + let mockMasterPasswordService: FakeMasterPasswordService; + let mockAccountService: FakeAccountService; // Mock authService.logIn params let code: string; @@ -117,6 +125,8 @@ describe("SsoComponent", () => { mockLogService = mock(); mockUserDecryptionOptionsService = mock(); mockConfigService = mock(); + mockAccountService = mockAccountServiceWith(userId); + mockMasterPasswordService = new FakeMasterPasswordService(); // Mock loginStrategyService.logIn params code = "code"; @@ -199,6 +209,8 @@ describe("SsoComponent", () => { }, { provide: LogService, useValue: mockLogService }, { provide: ConfigService, useValue: mockConfigService }, + { provide: InternalMasterPasswordServiceAbstraction, useValue: mockMasterPasswordService }, + { provide: AccountService, useValue: mockAccountService }, ], }); @@ -365,8 +377,9 @@ describe("SsoComponent", () => { await _component.logIn(code, codeVerifier, orgIdFromState); expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1); - expect(mockStateService.setForceSetPasswordReason).toHaveBeenCalledWith( + expect(mockMasterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith( ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, + userId, ); expect(mockOnSuccessfulLoginTdeNavigate).not.toHaveBeenCalled(); diff --git a/libs/angular/src/auth/components/sso.component.ts b/libs/angular/src/auth/components/sso.component.ts index 68d6e72e8d..30815beef8 100644 --- a/libs/angular/src/auth/components/sso.component.ts +++ b/libs/angular/src/auth/components/sso.component.ts @@ -11,6 +11,8 @@ import { UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; @@ -66,6 +68,8 @@ export class SsoComponent { protected logService: LogService, protected userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, protected configService: ConfigService, + protected masterPasswordService: InternalMasterPasswordServiceAbstraction, + protected accountService: AccountService, ) {} async ngOnInit() { @@ -290,8 +294,10 @@ export class SsoComponent { // Set flag so that auth guard can redirect to set password screen after decryption (trusted or untrusted device) // Note: we cannot directly navigate in this scenario as we are in a pre-decryption state, and // if you try to set a new MP before decrypting, you will invalidate the user's data by making a new user key. - await this.stateService.setForceSetPasswordReason( + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setForceSetPasswordReason( ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, + userId, ); } diff --git a/libs/angular/src/auth/components/two-factor.component.spec.ts b/libs/angular/src/auth/components/two-factor.component.spec.ts index bff39188ea..0eb248f6d9 100644 --- a/libs/angular/src/auth/components/two-factor.component.spec.ts +++ b/libs/angular/src/auth/components/two-factor.component.spec.ts @@ -15,11 +15,14 @@ import { UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -27,6 +30,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; import { TwoFactorComponent } from "./two-factor.component"; @@ -46,6 +51,7 @@ describe("TwoFactorComponent", () => { let _component: TwoFactorComponentProtected; let fixture: ComponentFixture; + const userId = "userId" as UserId; // Mock Services let mockLoginStrategyService: MockProxy; @@ -63,6 +69,8 @@ describe("TwoFactorComponent", () => { let mockUserDecryptionOptionsService: MockProxy; let mockSsoLoginService: MockProxy; let mockConfigService: MockProxy; + let mockMasterPasswordService: FakeMasterPasswordService; + let mockAccountService: FakeAccountService; let mockUserDecryptionOpts: { noMasterPassword: UserDecryptionOptions; @@ -93,6 +101,8 @@ describe("TwoFactorComponent", () => { mockUserDecryptionOptionsService = mock(); mockSsoLoginService = mock(); mockConfigService = mock(); + mockAccountService = mockAccountServiceWith(userId); + mockMasterPasswordService = new FakeMasterPasswordService(); mockUserDecryptionOpts = { noMasterPassword: new UserDecryptionOptions({ @@ -170,6 +180,8 @@ describe("TwoFactorComponent", () => { }, { provide: SsoLoginServiceAbstraction, useValue: mockSsoLoginService }, { provide: ConfigService, useValue: mockConfigService }, + { provide: InternalMasterPasswordServiceAbstraction, useValue: mockMasterPasswordService }, + { provide: AccountService, useValue: mockAccountService }, ], }); @@ -407,9 +419,9 @@ describe("TwoFactorComponent", () => { await component.doSubmit(); // Assert - - expect(mockStateService.setForceSetPasswordReason).toHaveBeenCalledWith( + expect(mockMasterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith( ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, + userId, ); expect(mockRouter.navigate).toHaveBeenCalledTimes(1); diff --git a/libs/angular/src/auth/components/two-factor.component.ts b/libs/angular/src/auth/components/two-factor.component.ts index c306e6cc80..f73f0483be 100644 --- a/libs/angular/src/auth/components/two-factor.component.ts +++ b/libs/angular/src/auth/components/two-factor.component.ts @@ -14,6 +14,8 @@ import { UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type"; @@ -92,6 +94,8 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI protected userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, protected ssoLoginService: SsoLoginServiceAbstraction, protected configService: ConfigService, + protected masterPasswordService: InternalMasterPasswordServiceAbstraction, + protected accountService: AccountService, ) { super(environmentService, i18nService, platformUtilsService); this.webAuthnSupported = this.platformUtilsService.supportsWebAuthn(win); @@ -342,8 +346,10 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI // Set flag so that auth guard can redirect to set password screen after decryption (trusted or untrusted device) // Note: we cannot directly navigate to the set password screen in this scenario as we are in a pre-decryption state, and // if you try to set a new MP before decrypting, you will invalidate the user's data by making a new user key. - await this.stateService.setForceSetPasswordReason( + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setForceSetPasswordReason( ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, + userId, ); } diff --git a/libs/angular/src/auth/components/update-temp-password.component.ts b/libs/angular/src/auth/components/update-temp-password.component.ts index 0b4541fe52..54fdc83239 100644 --- a/libs/angular/src/auth/components/update-temp-password.component.ts +++ b/libs/angular/src/auth/components/update-temp-password.component.ts @@ -1,9 +1,12 @@ import { Directive } from "@angular/core"; import { Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } 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 { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { VerificationType } from "@bitwarden/common/auth/enums/verification-type"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; @@ -56,6 +59,8 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent { private userVerificationService: UserVerificationService, protected router: Router, dialogService: DialogService, + private accountService: AccountService, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, ) { super( i18nService, @@ -72,7 +77,8 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent { async ngOnInit() { await this.syncService.fullSync(true); - this.reason = await this.stateService.getForceSetPasswordReason(); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + this.reason = await firstValueFrom(this.masterPasswordService.forceSetPasswordReason$(userId)); // If we somehow end up here without a reason, go back to the home page if (this.reason == ForceSetPasswordReason.None) { @@ -163,7 +169,11 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent { this.i18nService.t("updatedMasterPassword"), ); - await this.stateService.setForceSetPasswordReason(ForceSetPasswordReason.None); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setForceSetPasswordReason( + ForceSetPasswordReason.None, + userId, + ); if (this.onSuccessfulChangePassword != null) { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. diff --git a/libs/angular/src/auth/guards/auth.guard.ts b/libs/angular/src/auth/guards/auth.guard.ts index 29024cfa0b..b8e37d0af3 100644 --- a/libs/angular/src/auth/guards/auth.guard.ts +++ b/libs/angular/src/auth/guards/auth.guard.ts @@ -1,12 +1,14 @@ import { Injectable } from "@angular/core"; import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from "@angular/router"; +import { firstValueFrom } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; +import { MasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; -import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; @Injectable() export class AuthGuard implements CanActivate { @@ -15,7 +17,8 @@ export class AuthGuard implements CanActivate { private router: Router, private messagingService: MessagingService, private keyConnectorService: KeyConnectorService, - private stateService: StateService, + private accountService: AccountService, + private masterPasswordService: MasterPasswordServiceAbstraction, ) {} async canActivate(route: ActivatedRouteSnapshot, routerState: RouterStateSnapshot) { @@ -40,7 +43,10 @@ export class AuthGuard implements CanActivate { return this.router.createUrlTree(["/remove-password"]); } - const forceSetPasswordReason = await this.stateService.getForceSetPasswordReason(); + const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; + const forceSetPasswordReason = await firstValueFrom( + this.masterPasswordService.forceSetPasswordReason$(userId), + ); if ( forceSetPasswordReason === diff --git a/libs/angular/src/auth/guards/unauth.guard.ts b/libs/angular/src/auth/guards/unauth.guard.ts index 35c59b5744..9e1bca98ca 100644 --- a/libs/angular/src/auth/guards/unauth.guard.ts +++ b/libs/angular/src/auth/guards/unauth.guard.ts @@ -2,7 +2,6 @@ import { Injectable, inject } from "@angular/core"; import { CanActivate, CanActivateFn, Router, UrlTree } from "@angular/router"; import { Observable, map } from "rxjs"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; @@ -43,14 +42,14 @@ const defaultRoutes: UnauthRoutes = { }; function unauthGuard(routes: UnauthRoutes): Observable { - const accountService = inject(AccountService); + const authService = inject(AuthService); const router = inject(Router); - return accountService.activeAccount$.pipe( - map((accountData) => { - if (accountData == null || accountData.status === AuthenticationStatus.LoggedOut) { + return authService.activeAccountStatus$.pipe( + map((status) => { + if (status == null || status === AuthenticationStatus.LoggedOut) { return true; - } else if (accountData.status === AuthenticationStatus.Locked) { + } else if (status === AuthenticationStatus.Locked) { return router.createUrlTree([routes.locked]); } else { return router.createUrlTree([routes.homepage()]); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 6adc9af88d..efc2e93990 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -60,6 +60,10 @@ import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abst import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/auth/abstractions/key-connector.service"; +import { + InternalMasterPasswordServiceAbstraction, + MasterPasswordServiceAbstraction, +} from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/abstractions/token.service"; @@ -78,6 +82,7 @@ import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation"; import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; import { KeyConnectorService } from "@bitwarden/common/auth/services/key-connector.service"; +import { MasterPasswordService } from "@bitwarden/common/auth/services/master-password/master-password.service"; import { PasswordResetEnrollmentServiceImplementation } from "@bitwarden/common/auth/services/password-reset-enrollment.service.implementation"; import { SsoLoginService } from "@bitwarden/common/auth/services/sso-login.service"; import { TokenService } from "@bitwarden/common/auth/services/token.service"; @@ -360,6 +365,8 @@ const safeProviders: SafeProvider[] = [ provide: LoginStrategyServiceAbstraction, useClass: LoginStrategyService, deps: [ + AccountServiceAbstraction, + InternalMasterPasswordServiceAbstraction, CryptoServiceAbstraction, ApiServiceAbstraction, TokenServiceAbstraction, @@ -406,6 +413,7 @@ const safeProviders: SafeProvider[] = [ encryptService: EncryptService, fileUploadService: CipherFileUploadServiceAbstraction, configService: ConfigService, + stateProvider: StateProvider, ) => new CipherService( cryptoService, @@ -418,6 +426,7 @@ const safeProviders: SafeProvider[] = [ encryptService, fileUploadService, configService, + stateProvider, ), deps: [ CryptoServiceAbstraction, @@ -430,6 +439,7 @@ const safeProviders: SafeProvider[] = [ EncryptService, CipherFileUploadServiceAbstraction, ConfigService, + StateProvider, ], }), safeProvider({ @@ -439,7 +449,6 @@ const safeProviders: SafeProvider[] = [ CryptoServiceAbstraction, I18nServiceAbstraction, CipherServiceAbstraction, - StateServiceAbstraction, StateProvider, ], }), @@ -523,6 +532,7 @@ const safeProviders: SafeProvider[] = [ provide: CryptoServiceAbstraction, useClass: CryptoService, deps: [ + InternalMasterPasswordServiceAbstraction, KeyGenerationServiceAbstraction, CryptoFunctionServiceAbstraction, EncryptService, @@ -589,6 +599,8 @@ const safeProviders: SafeProvider[] = [ provide: SyncServiceAbstraction, useClass: SyncService, deps: [ + InternalMasterPasswordServiceAbstraction, + AccountServiceAbstraction, ApiServiceAbstraction, DomainSettingsService, InternalFolderService, @@ -628,6 +640,8 @@ const safeProviders: SafeProvider[] = [ provide: VaultTimeoutService, useClass: VaultTimeoutService, deps: [ + AccountServiceAbstraction, + InternalMasterPasswordServiceAbstraction, CipherServiceAbstraction, FolderServiceAbstraction, CollectionServiceAbstraction, @@ -716,7 +730,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: SearchServiceAbstraction, useClass: SearchService, - deps: [LogService, I18nServiceAbstraction], + deps: [LogService, I18nServiceAbstraction, StateProvider], }), safeProvider({ provide: NotificationsServiceAbstraction, @@ -730,6 +744,7 @@ const safeProviders: SafeProvider[] = [ LOGOUT_CALLBACK, StateServiceAbstraction, AuthServiceAbstraction, + AuthRequestServiceAbstraction, MessagingServiceAbstraction, TaskSchedulerService, ], @@ -751,7 +766,7 @@ const safeProviders: SafeProvider[] = [ ApiServiceAbstraction, StateProvider, LogService, - AccountServiceAbstraction, + AuthServiceAbstraction, TaskSchedulerService, ], }), @@ -763,7 +778,7 @@ const safeProviders: SafeProvider[] = [ StateProvider, OrganizationServiceAbstraction, EventUploadServiceAbstraction, - AccountServiceAbstraction, + AuthServiceAbstraction, ], }), safeProvider({ @@ -780,10 +795,21 @@ const safeProviders: SafeProvider[] = [ useClass: PolicyApiService, deps: [InternalPolicyService, ApiServiceAbstraction], }), + safeProvider({ + provide: InternalMasterPasswordServiceAbstraction, + useClass: MasterPasswordService, + deps: [StateProvider], + }), + safeProvider({ + provide: MasterPasswordServiceAbstraction, + useExisting: InternalMasterPasswordServiceAbstraction, + }), safeProvider({ provide: KeyConnectorServiceAbstraction, useClass: KeyConnectorService, deps: [ + AccountServiceAbstraction, + InternalMasterPasswordServiceAbstraction, CryptoServiceAbstraction, ApiServiceAbstraction, TokenServiceAbstraction, @@ -800,6 +826,8 @@ const safeProviders: SafeProvider[] = [ deps: [ StateServiceAbstraction, CryptoServiceAbstraction, + AccountServiceAbstraction, + InternalMasterPasswordServiceAbstraction, I18nServiceAbstraction, UserVerificationApiServiceAbstraction, UserDecryptionOptionsServiceAbstraction, @@ -943,9 +971,11 @@ const safeProviders: SafeProvider[] = [ useClass: AuthRequestService, deps: [ AppIdServiceAbstraction, + AccountServiceAbstraction, + InternalMasterPasswordServiceAbstraction, CryptoServiceAbstraction, ApiServiceAbstraction, - StateServiceAbstraction, + StateProvider, ], }), safeProvider({ diff --git a/libs/angular/src/services/unassigned-items-banner.api.service.ts b/libs/angular/src/services/unassigned-items-banner.api.service.ts new file mode 100644 index 0000000000..69b74f8c7f --- /dev/null +++ b/libs/angular/src/services/unassigned-items-banner.api.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from "@angular/core"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; + +@Injectable({ providedIn: "root" }) +export class UnassignedItemsBannerApiService { + constructor(private apiService: ApiService) {} + + async getShowUnassignedCiphersBanner(): Promise { + const r = await this.apiService.send( + "GET", + "/ciphers/has-unassigned-ciphers", + null, + true, + true, + ); + return r; + } +} diff --git a/libs/angular/src/services/unassigned-items-banner.service.spec.ts b/libs/angular/src/services/unassigned-items-banner.service.spec.ts new file mode 100644 index 0000000000..ca2487a518 --- /dev/null +++ b/libs/angular/src/services/unassigned-items-banner.service.spec.ts @@ -0,0 +1,56 @@ +import { MockProxy, mock } from "jest-mock-extended"; +import { firstValueFrom, of } from "rxjs"; + +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { UnassignedItemsBannerApiService } from "./unassigned-items-banner.api.service"; +import { SHOW_BANNER_KEY, UnassignedItemsBannerService } from "./unassigned-items-banner.service"; + +describe("UnassignedItemsBanner", () => { + let stateProvider: FakeStateProvider; + let apiService: MockProxy; + let environmentService: MockProxy; + + const sutFactory = () => + new UnassignedItemsBannerService(stateProvider, apiService, environmentService); + + beforeEach(() => { + const fakeAccountService = mockAccountServiceWith("userId" as UserId); + stateProvider = new FakeStateProvider(fakeAccountService); + apiService = mock(); + environmentService = mock(); + environmentService.environment$ = of(null); + }); + + it("shows the banner if showBanner local state is true", async () => { + const showBanner = stateProvider.activeUser.getFake(SHOW_BANNER_KEY); + showBanner.nextState(true); + + const sut = sutFactory(); + expect(await firstValueFrom(sut.showBanner$)).toBe(true); + expect(apiService.getShowUnassignedCiphersBanner).not.toHaveBeenCalled(); + }); + + it("does not show the banner if showBanner local state is false", async () => { + const showBanner = stateProvider.activeUser.getFake(SHOW_BANNER_KEY); + showBanner.nextState(false); + + const sut = sutFactory(); + expect(await firstValueFrom(sut.showBanner$)).toBe(false); + expect(apiService.getShowUnassignedCiphersBanner).not.toHaveBeenCalled(); + }); + + it("fetches from server if local state has not been set yet", async () => { + apiService.getShowUnassignedCiphersBanner.mockResolvedValue(true); + + const showBanner = stateProvider.activeUser.getFake(SHOW_BANNER_KEY); + showBanner.nextState(undefined); + + const sut = sutFactory(); + + expect(await firstValueFrom(sut.showBanner$)).toBe(true); + expect(apiService.getShowUnassignedCiphersBanner).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/angular/src/services/unassigned-items-banner.service.ts b/libs/angular/src/services/unassigned-items-banner.service.ts new file mode 100644 index 0000000000..13a745fb82 --- /dev/null +++ b/libs/angular/src/services/unassigned-items-banner.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from "@angular/core"; +import { concatMap, map } from "rxjs"; + +import { + EnvironmentService, + Region, +} from "@bitwarden/common/platform/abstractions/environment.service"; +import { + StateProvider, + UNASSIGNED_ITEMS_BANNER_DISK, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; + +import { UnassignedItemsBannerApiService } from "./unassigned-items-banner.api.service"; + +export const SHOW_BANNER_KEY = new UserKeyDefinition( + UNASSIGNED_ITEMS_BANNER_DISK, + "showBanner", + { + deserializer: (b) => b, + clearOn: [], + }, +); + +/** Displays a banner that tells users how to move their unassigned items into a collection. */ +@Injectable({ providedIn: "root" }) +export class UnassignedItemsBannerService { + private _showBanner = this.stateProvider.getActive(SHOW_BANNER_KEY); + + showBanner$ = this._showBanner.state$.pipe( + concatMap(async (showBannerState) => { + // null indicates that the user has not seen or dismissed the banner yet - get the flag from server + if (showBannerState == null) { + const showBannerResponse = await this.apiService.getShowUnassignedCiphersBanner(); + await this._showBanner.update(() => showBannerResponse); + return showBannerResponse; + } + + return showBannerState; + }), + ); + + bannerText$ = this.environmentService.environment$.pipe( + map((e) => + e?.getRegion() == Region.SelfHosted + ? "unassignedItemsBannerSelfHost" + : "unassignedItemsBanner", + ), + ); + + constructor( + private stateProvider: StateProvider, + private apiService: UnassignedItemsBannerApiService, + private environmentService: EnvironmentService, + ) {} + + async hideBanner() { + await this._showBanner.update(() => false); + } +} diff --git a/libs/angular/src/tools/send/send.component.ts b/libs/angular/src/tools/send/send.component.ts index 90d9b39e8c..fc51e32416 100644 --- a/libs/angular/src/tools/send/send.component.ts +++ b/libs/angular/src/tools/send/send.component.ts @@ -1,5 +1,13 @@ import { Directive, NgZone, OnDestroy, OnInit } from "@angular/core"; -import { Subject, firstValueFrom, mergeMap, takeUntil } from "rxjs"; +import { + BehaviorSubject, + Subject, + firstValueFrom, + mergeMap, + from, + switchMap, + takeUntil, +} from "rxjs"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -24,7 +32,6 @@ export class SendComponent implements OnInit, OnDestroy { expired = false; type: SendType = null; sends: SendView[] = []; - searchText: string; selectedType: SendType; selectedAll: boolean; filter: (cipher: SendView) => boolean; @@ -39,6 +46,8 @@ export class SendComponent implements OnInit, OnDestroy { private searchTimeout: any; private destroy$ = new Subject(); private _filteredSends: SendView[]; + private _searchText$ = new BehaviorSubject(""); + protected isSearchable: boolean = false; get filteredSends(): SendView[] { return this._filteredSends; @@ -48,6 +57,14 @@ export class SendComponent implements OnInit, OnDestroy { this._filteredSends = filteredSends; } + get searchText() { + return this._searchText$.value; + } + + set searchText(value: string) { + this._searchText$.next(value); + } + constructor( protected sendService: SendService, protected i18nService: I18nService, @@ -68,6 +85,15 @@ export class SendComponent implements OnInit, OnDestroy { .subscribe((policyAppliesToActiveUser) => { this.disableSend = policyAppliesToActiveUser; }); + + this._searchText$ + .pipe( + switchMap((searchText) => from(this.searchService.isSearchable(searchText))), + takeUntil(this.destroy$), + ) + .subscribe((isSearchable) => { + this.isSearchable = isSearchable; + }); } ngOnDestroy() { @@ -122,14 +148,14 @@ export class SendComponent implements OnInit, OnDestroy { clearTimeout(this.searchTimeout); } if (timeout == null) { - this.hasSearched = this.searchService.isSearchable(this.searchText); + this.hasSearched = this.isSearchable; this.filteredSends = this.sends.filter((s) => this.filter == null || this.filter(s)); this.applyTextSearch(); return; } this.searchPending = true; this.searchTimeout = setTimeout(async () => { - this.hasSearched = this.searchService.isSearchable(this.searchText); + this.hasSearched = this.isSearchable; this.filteredSends = this.sends.filter((s) => this.filter == null || this.filter(s)); this.applyTextSearch(); this.searchPending = false; diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index 6a0cfde350..d29c74b42d 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -1,6 +1,6 @@ import { DatePipe } from "@angular/common"; import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; -import { concatMap, Observable, Subject, takeUntil } from "rxjs"; +import { concatMap, firstValueFrom, Observable, Subject, takeUntil } from "rxjs"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; @@ -592,7 +592,7 @@ export class AddEditComponent implements OnInit, OnDestroy { this.writeableCollections.forEach((c) => ((c as any).checked = false)); } if (this.cipher.organizationId != null) { - this.collections = this.writeableCollections.filter( + this.collections = this.writeableCollections?.filter( (c) => c.organizationId === this.cipher.organizationId, ); const org = await this.organizationService.get(this.cipher.organizationId); @@ -687,7 +687,7 @@ export class AddEditComponent implements OnInit, OnDestroy { } async loadAddEditCipherInfo(): Promise { - const addEditCipherInfo: any = await this.stateService.getAddEditCipherInfo(); + const addEditCipherInfo: any = await firstValueFrom(this.cipherService.addEditCipherInfo$); const loadedSavedInfo = addEditCipherInfo != null; if (loadedSavedInfo) { @@ -700,7 +700,7 @@ export class AddEditComponent implements OnInit, OnDestroy { } } - await this.stateService.setAddEditCipherInfo(null); + await this.cipherService.setAddEditCipherInfo(null); return loadedSavedInfo; } diff --git a/libs/angular/src/vault/components/vault-items.component.ts b/libs/angular/src/vault/components/vault-items.component.ts index cdfb1b6299..20e779e77c 100644 --- a/libs/angular/src/vault/components/vault-items.component.ts +++ b/libs/angular/src/vault/components/vault-items.component.ts @@ -1,4 +1,5 @@ -import { Directive, EventEmitter, Input, Output } from "@angular/core"; +import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { BehaviorSubject, Subject, from, switchMap, takeUntil } from "rxjs"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -6,7 +7,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @Directive() -export class VaultItemsComponent { +export class VaultItemsComponent implements OnInit, OnDestroy { @Input() activeCipherId: string = null; @Output() onCipherClicked = new EventEmitter(); @Output() onCipherRightClicked = new EventEmitter(); @@ -23,13 +24,15 @@ export class VaultItemsComponent { protected searchPending = false; + private destroy$ = new Subject(); private searchTimeout: any = null; - private _searchText: string = null; + private isSearchable: boolean = false; + private _searchText$ = new BehaviorSubject(""); get searchText() { - return this._searchText; + return this._searchText$.value; } set searchText(value: string) { - this._searchText = value; + this._searchText$.next(value); } constructor( @@ -37,6 +40,22 @@ export class VaultItemsComponent { protected cipherService: CipherService, ) {} + ngOnInit(): void { + this._searchText$ + .pipe( + switchMap((searchText) => from(this.searchService.isSearchable(searchText))), + takeUntil(this.destroy$), + ) + .subscribe((isSearchable) => { + this.isSearchable = isSearchable; + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + async load(filter: (cipher: CipherView) => boolean = null, deleted = false) { this.deleted = deleted ?? false; await this.applyFilter(filter); @@ -90,7 +109,7 @@ export class VaultItemsComponent { } isSearching() { - return !this.searchPending && this.searchService.isSearchable(this.searchText); + return !this.searchPending && this.isSearchable; } protected deletedFilter: (cipher: CipherView) => boolean = (c) => c.isDeleted === this.deleted; diff --git a/libs/auth/src/common/abstractions/auth-request.service.abstraction.ts b/libs/auth/src/common/abstractions/auth-request.service.abstraction.ts index 7af92fc8f8..b7ae903eac 100644 --- a/libs/auth/src/common/abstractions/auth-request.service.abstraction.ts +++ b/libs/auth/src/common/abstractions/auth-request.service.abstraction.ts @@ -1,12 +1,52 @@ import { Observable } from "rxjs"; +import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/admin-auth-req-storable"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response"; +import { UserId } from "@bitwarden/common/types/guid"; import { UserKey, MasterKey } from "@bitwarden/common/types/key"; export abstract class AuthRequestServiceAbstraction { /** Emits an auth request id when an auth request has been approved. */ authRequestPushNotification$: Observable; + + /** + * Returns true if the user has chosen to allow auth requests to show on this client. + * Intended to prevent spamming the user with auth requests. + * @param userId The user id. + * @throws If `userId` is not provided. + */ + abstract getAcceptAuthRequests: (userId: UserId) => Promise; + /** + * Sets whether to allow auth requests to show on this client for this user. + * @param accept Whether to allow auth requests to show on this client. + * @param userId The user id. + * @throws If `userId` is not provided. + */ + abstract setAcceptAuthRequests: (accept: boolean, userId: UserId) => Promise; + /** + * Returns an admin auth request for the given user if it exists. + * @param userId The user id. + * @throws If `userId` is not provided. + */ + abstract getAdminAuthRequest: (userId: UserId) => Promise; + /** + * Sets an admin auth request for the given user. + * Note: use {@link clearAdminAuthRequest} to clear the request. + * @param authRequest The admin auth request. + * @param userId The user id. + * @throws If `authRequest` or `userId` is not provided. + */ + abstract setAdminAuthRequest: ( + authRequest: AdminAuthRequestStorable, + userId: UserId, + ) => Promise; + /** + * Clears an admin auth request for the given user. + * @param userId The user id. + * @throws If `userId` is not provided. + */ + abstract clearAdminAuthRequest: (userId: UserId) => Promise; /** * Approve or deny an auth request. * @param approve True to approve, false to deny. diff --git a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts index 53722cd259..0ce6c9fed7 100644 --- a/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/auth-request-login.strategy.spec.ts @@ -5,6 +5,7 @@ import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abst import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -14,7 +15,9 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { UserId } from "@bitwarden/common/types/guid"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction"; @@ -42,6 +45,10 @@ describe("AuthRequestLoginStrategy", () => { let deviceTrustCryptoService: MockProxy; let billingAccountProfileStateService: MockProxy; + const mockUserId = Utils.newGuid() as UserId; + let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; + let authRequestLoginStrategy: AuthRequestLoginStrategy; let credentials: AuthRequestLoginCredentials; let tokenResponse: IdentityTokenResponse; @@ -71,12 +78,17 @@ describe("AuthRequestLoginStrategy", () => { deviceTrustCryptoService = mock(); billingAccountProfileStateService = mock(); + accountService = mockAccountServiceWith(mockUserId); + masterPasswordService = new FakeMasterPasswordService(); + tokenService.getTwoFactorToken.mockResolvedValue(null); appIdService.getAppId.mockResolvedValue(deviceId); tokenService.decodeAccessToken.mockResolvedValue({}); authRequestLoginStrategy = new AuthRequestLoginStrategy( cache, + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -108,13 +120,16 @@ describe("AuthRequestLoginStrategy", () => { const masterKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as MasterKey; const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; - cryptoService.getMasterKey.mockResolvedValue(masterKey); + masterPasswordService.masterKeySubject.next(masterKey); cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); await authRequestLoginStrategy.logIn(credentials); - expect(cryptoService.setMasterKey).toHaveBeenCalledWith(masterKey); - expect(cryptoService.setMasterKeyHash).toHaveBeenCalledWith(decMasterKeyHash); + expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(masterKey, mockUserId); + expect(masterPasswordService.mock.setMasterKeyHash).toHaveBeenCalledWith( + decMasterKeyHash, + mockUserId, + ); expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key); expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey); expect(deviceTrustCryptoService.trustDeviceIfRequired).toHaveBeenCalled(); @@ -136,8 +151,8 @@ describe("AuthRequestLoginStrategy", () => { await authRequestLoginStrategy.logIn(credentials); // setMasterKey and setMasterKeyHash should not be called - expect(cryptoService.setMasterKey).not.toHaveBeenCalled(); - expect(cryptoService.setMasterKeyHash).not.toHaveBeenCalled(); + expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled(); + expect(masterPasswordService.mock.setMasterKeyHash).not.toHaveBeenCalled(); // setMasterKeyEncryptedUserKey, setUserKey, and setPrivateKey should still be called expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key); 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 31a0cebbfe..4035a7be58 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 @@ -1,8 +1,10 @@ -import { Observable, map, BehaviorSubject } from "rxjs"; +import { firstValueFrom, Observable, map, BehaviorSubject } from "rxjs"; import { Jsonify } from "type-fest"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; @@ -47,6 +49,8 @@ export class AuthRequestLoginStrategy extends LoginStrategy { constructor( data: AuthRequestLoginStrategyData, + accountService: AccountService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, cryptoService: CryptoService, apiService: ApiService, tokenService: TokenService, @@ -61,6 +65,8 @@ export class AuthRequestLoginStrategy extends LoginStrategy { billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -114,12 +120,22 @@ export class AuthRequestLoginStrategy extends LoginStrategy { authRequestCredentials.decryptedMasterKey && authRequestCredentials.decryptedMasterKeyHash ) { - await this.cryptoService.setMasterKey(authRequestCredentials.decryptedMasterKey); - await this.cryptoService.setMasterKeyHash(authRequestCredentials.decryptedMasterKeyHash); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setMasterKey( + authRequestCredentials.decryptedMasterKey, + userId, + ); + await this.masterPasswordService.setMasterKeyHash( + authRequestCredentials.decryptedMasterKeyHash, + userId, + ); } } - protected override async setUserKey(response: IdentityTokenResponse): Promise { + protected override async setUserKey( + response: IdentityTokenResponse, + userId: UserId, + ): Promise { const authRequestCredentials = this.cache.value.authRequestCredentials; // User now may or may not have a master password // but set the master key encrypted user key if it exists regardless @@ -130,14 +146,14 @@ export class AuthRequestLoginStrategy extends LoginStrategy { } else { await this.trySetUserKeyWithMasterKey(); - const userId = (await this.stateService.getUserId()) as UserId; // Establish trust if required after setting user key await this.deviceTrustCryptoService.trustDeviceIfRequired(userId); } } private async trySetUserKeyWithMasterKey(): Promise { - const masterKey = await this.cryptoService.getMasterKey(); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); if (masterKey) { const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey); await this.cryptoService.setUserKey(userKey); 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 0ac22047c5..431f736e94 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.spec.ts @@ -14,6 +14,7 @@ import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/id import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response"; import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; @@ -31,11 +32,13 @@ import { } from "@bitwarden/common/platform/models/domain/account"; 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 { PasswordStrengthServiceAbstraction, PasswordStrengthService, } from "@bitwarden/common/tools/password-strength"; import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { UserId } from "@bitwarden/common/types/guid"; import { UserKey, MasterKey } from "@bitwarden/common/types/key"; import { LoginStrategyServiceAbstraction } from "../abstractions"; @@ -56,7 +59,7 @@ const privateKey = "PRIVATE_KEY"; const captchaSiteKey = "CAPTCHA_SITE_KEY"; const kdf = 0; const kdfIterations = 10000; -const userId = Utils.newGuid(); +const userId = Utils.newGuid() as UserId; const masterPasswordHash = "MASTER_PASSWORD_HASH"; const name = "NAME"; const defaultUserDecryptionOptionsServerResponse: IUserDecryptionOptionsServerResponse = { @@ -98,6 +101,8 @@ export function identityTokenResponseFactory( // TODO: add tests for latest changes to base class for TDE describe("LoginStrategy", () => { let cache: PasswordLoginStrategyData; + let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; let loginStrategyService: MockProxy; let cryptoService: MockProxy; @@ -118,6 +123,9 @@ describe("LoginStrategy", () => { let credentials: PasswordLoginCredentials; beforeEach(async () => { + accountService = mockAccountServiceWith(userId); + masterPasswordService = new FakeMasterPasswordService(); + loginStrategyService = mock(); cryptoService = mock(); apiService = mock(); @@ -139,6 +147,8 @@ describe("LoginStrategy", () => { // The base class is abstract so we test it via PasswordLoginStrategy passwordLoginStrategy = new PasswordLoginStrategy( cache, + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -241,7 +251,7 @@ describe("LoginStrategy", () => { }); apiService.postIdentityToken.mockResolvedValue(tokenResponse); - cryptoService.getMasterKey.mockResolvedValue(masterKey); + masterPasswordService.masterKeySubject.next(masterKey); cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); const result = await passwordLoginStrategy.logIn(credentials); @@ -260,7 +270,7 @@ describe("LoginStrategy", () => { cryptoService.makeKeyPair.mockResolvedValue(["PUBLIC_KEY", new EncString("PRIVATE_KEY")]); apiService.postIdentityToken.mockResolvedValue(tokenResponse); - cryptoService.getMasterKey.mockResolvedValue(masterKey); + masterPasswordService.masterKeySubject.next(masterKey); cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); await passwordLoginStrategy.logIn(credentials); @@ -382,6 +392,8 @@ describe("LoginStrategy", () => { passwordLoginStrategy = new PasswordLoginStrategy( cache, + accountService, + masterPasswordService, cryptoService, apiService, tokenService, diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index 4fe99b276c..94f96d40d0 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -1,6 +1,8 @@ import { BehaviorSubject } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type"; @@ -30,6 +32,7 @@ import { AccountProfile, AccountTokens, } from "@bitwarden/common/platform/models/domain/account"; +import { UserId } from "@bitwarden/common/types/guid"; import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction"; import { @@ -60,6 +63,8 @@ export abstract class LoginStrategy { protected abstract cache: BehaviorSubject; constructor( + protected accountService: AccountService, + protected masterPasswordService: InternalMasterPasswordServiceAbstraction, protected cryptoService: CryptoService, protected apiService: ApiService, protected tokenService: TokenService, @@ -156,14 +161,11 @@ export abstract class LoginStrategy { * @param {IdentityTokenResponse} tokenResponse - The response from the server containing the identity token. * @returns {Promise} - A promise that resolves when the account information has been successfully saved. */ - protected async saveAccountInformation(tokenResponse: IdentityTokenResponse): Promise { + protected async saveAccountInformation(tokenResponse: IdentityTokenResponse): Promise { const accountInformation = await this.tokenService.decodeAccessToken(tokenResponse.accessToken); const userId = accountInformation.sub; - // If you don't persist existing admin auth requests on login, they will get deleted. - const adminAuthRequest = await this.stateService.getAdminAuthRequest({ userId }); - const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction(); const vaultTimeout = await this.stateService.getVaultTimeout(); @@ -193,7 +195,6 @@ export abstract class LoginStrategy { tokens: { ...new AccountTokens(), }, - adminAuthRequest: adminAuthRequest?.toJSON(), }), ); @@ -202,6 +203,7 @@ export abstract class LoginStrategy { ); await this.billingAccountProfileStateService.setHasPremium(accountInformation.premium, false); + return userId as UserId; } protected async processTokenResponse(response: IdentityTokenResponse): Promise { @@ -224,7 +226,7 @@ export abstract class LoginStrategy { } // Must come before setting keys, user key needs email to update additional keys - await this.saveAccountInformation(response); + const userId = await this.saveAccountInformation(response); if (response.twoFactorToken != null) { // note: we can read email from access token b/c it was saved in saveAccountInformation @@ -234,7 +236,7 @@ export abstract class LoginStrategy { } await this.setMasterKey(response); - await this.setUserKey(response); + await this.setUserKey(response, userId); await this.setPrivateKey(response); this.messagingService.send("loggedIn"); @@ -244,7 +246,7 @@ export abstract class LoginStrategy { // The keys comes from different sources depending on the login strategy protected abstract setMasterKey(response: IdentityTokenResponse): Promise; - protected abstract setUserKey(response: IdentityTokenResponse): Promise; + protected abstract setUserKey(response: IdentityTokenResponse, userId: UserId): Promise; protected abstract setPrivateKey(response: IdentityTokenResponse): Promise; // Old accounts used master key for encryption. We are forcing migrations but only need to diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts index 470a4ac713..b902fff574 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.spec.ts @@ -9,6 +9,7 @@ import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/for import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -19,11 +20,13 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv import { HashPurpose } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { PasswordStrengthServiceAbstraction, PasswordStrengthService, } from "@bitwarden/common/tools/password-strength"; import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { UserId } from "@bitwarden/common/types/guid"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { LoginStrategyServiceAbstraction } from "../abstractions"; @@ -42,6 +45,7 @@ const masterKey = new SymmetricCryptoKey( "N2KWjlLpfi5uHjv+YcfUKIpZ1l+W+6HRensmIqD+BFYBf6N/dvFpJfWwYnVBdgFCK2tJTAIMLhqzIQQEUmGFgg==", ), ) as MasterKey; +const userId = Utils.newGuid() as UserId; const deviceId = Utils.newGuid(); const masterPasswordPolicy = new MasterPasswordPolicyResponse({ EnforceOnLogin: true, @@ -50,6 +54,8 @@ const masterPasswordPolicy = new MasterPasswordPolicyResponse({ describe("PasswordLoginStrategy", () => { let cache: PasswordLoginStrategyData; + let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; let loginStrategyService: MockProxy; let cryptoService: MockProxy; @@ -71,6 +77,9 @@ describe("PasswordLoginStrategy", () => { let tokenResponse: IdentityTokenResponse; beforeEach(async () => { + accountService = mockAccountServiceWith(userId); + masterPasswordService = new FakeMasterPasswordService(); + loginStrategyService = mock(); cryptoService = mock(); apiService = mock(); @@ -102,6 +111,8 @@ describe("PasswordLoginStrategy", () => { passwordLoginStrategy = new PasswordLoginStrategy( cache, + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -145,13 +156,16 @@ describe("PasswordLoginStrategy", () => { it("sets keys after a successful authentication", async () => { const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey; - cryptoService.getMasterKey.mockResolvedValue(masterKey); + masterPasswordService.masterKeySubject.next(masterKey); cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); await passwordLoginStrategy.logIn(credentials); - expect(cryptoService.setMasterKey).toHaveBeenCalledWith(masterKey); - expect(cryptoService.setMasterKeyHash).toHaveBeenCalledWith(localHashedPassword); + expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(masterKey, userId); + expect(masterPasswordService.mock.setMasterKeyHash).toHaveBeenCalledWith( + localHashedPassword, + userId, + ); expect(cryptoService.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(tokenResponse.key); expect(cryptoService.setUserKey).toHaveBeenCalledWith(userKey); expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(tokenResponse.privateKey); @@ -183,8 +197,9 @@ describe("PasswordLoginStrategy", () => { const result = await passwordLoginStrategy.logIn(credentials); expect(policyService.evaluateMasterPassword).toHaveBeenCalled(); - expect(stateService.setForceSetPasswordReason).toHaveBeenCalledWith( + expect(masterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith( ForceSetPasswordReason.WeakMasterPassword, + userId, ); expect(result.forcePasswordReset).toEqual(ForceSetPasswordReason.WeakMasterPassword); }); @@ -222,8 +237,9 @@ describe("PasswordLoginStrategy", () => { expect(firstResult.forcePasswordReset).toEqual(ForceSetPasswordReason.None); // Second login attempt should save the force password reset options and return in result - expect(stateService.setForceSetPasswordReason).toHaveBeenCalledWith( + expect(masterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith( ForceSetPasswordReason.WeakMasterPassword, + userId, ); expect(secondResult.forcePasswordReset).toEqual(ForceSetPasswordReason.WeakMasterPassword); }); diff --git a/libs/auth/src/common/login-strategies/password-login.strategy.ts b/libs/auth/src/common/login-strategies/password-login.strategy.ts index d3de3ea6ba..2490c35a00 100644 --- a/libs/auth/src/common/login-strategies/password-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/password-login.strategy.ts @@ -1,9 +1,11 @@ -import { BehaviorSubject, map, Observable } from "rxjs"; +import { BehaviorSubject, firstValueFrom, map, Observable } from "rxjs"; import { Jsonify } from "type-fest"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } 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 { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; @@ -23,6 +25,7 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv import { HashPurpose } from "@bitwarden/common/platform/enums"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { UserId } from "@bitwarden/common/types/guid"; import { MasterKey } from "@bitwarden/common/types/key"; import { LoginStrategyServiceAbstraction } from "../abstractions"; @@ -70,6 +73,8 @@ export class PasswordLoginStrategy extends LoginStrategy { constructor( data: PasswordLoginStrategyData, + accountService: AccountService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, cryptoService: CryptoService, apiService: ApiService, tokenService: TokenService, @@ -86,6 +91,8 @@ export class PasswordLoginStrategy extends LoginStrategy { billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -157,8 +164,10 @@ export class PasswordLoginStrategy extends LoginStrategy { }); } else { // Authentication was successful, save the force update password options with the state service - await this.stateService.setForceSetPasswordReason( + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setForceSetPasswordReason( ForceSetPasswordReason.WeakMasterPassword, + userId, ); authResult.forcePasswordReset = ForceSetPasswordReason.WeakMasterPassword; } @@ -184,7 +193,8 @@ export class PasswordLoginStrategy extends LoginStrategy { !result.requiresCaptcha && forcePasswordResetReason != ForceSetPasswordReason.None ) { - await this.stateService.setForceSetPasswordReason(forcePasswordResetReason); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setForceSetPasswordReason(forcePasswordResetReason, userId); result.forcePasswordReset = forcePasswordResetReason; } @@ -193,18 +203,22 @@ export class PasswordLoginStrategy extends LoginStrategy { protected override async setMasterKey(response: IdentityTokenResponse) { const { masterKey, localMasterKeyHash } = this.cache.value; - await this.cryptoService.setMasterKey(masterKey); - await this.cryptoService.setMasterKeyHash(localMasterKeyHash); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setMasterKey(masterKey, userId); + await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, userId); } - protected override async setUserKey(response: IdentityTokenResponse): Promise { + protected override async setUserKey( + response: IdentityTokenResponse, + userId: UserId, + ): Promise { // If migration is required, we won't have a user key to set yet. if (this.encryptionKeyMigrationRequired(response)) { return; } await this.cryptoService.setMasterKeyEncryptedUserKey(response.key); - const masterKey = await this.cryptoService.getMasterKey(); + const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); if (masterKey) { const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey); await this.cryptoService.setUserKey(userKey); diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts index d4b0b13eaf..b78ad6dea6 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts @@ -9,6 +9,7 @@ import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/a import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; @@ -20,7 +21,9 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { UserId } from "@bitwarden/common/types/guid"; import { DeviceKey, UserKey, MasterKey } from "@bitwarden/common/types/key"; import { @@ -33,6 +36,9 @@ import { identityTokenResponseFactory } from "./login.strategy.spec"; import { SsoLoginStrategy } from "./sso-login.strategy"; describe("SsoLoginStrategy", () => { + let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; + let cryptoService: MockProxy; let apiService: MockProxy; let tokenService: MockProxy; @@ -52,6 +58,7 @@ describe("SsoLoginStrategy", () => { let ssoLoginStrategy: SsoLoginStrategy; let credentials: SsoLoginCredentials; + const userId = Utils.newGuid() as UserId; const deviceId = Utils.newGuid(); const keyConnectorUrl = "KEY_CONNECTOR_URL"; @@ -61,6 +68,9 @@ describe("SsoLoginStrategy", () => { const ssoOrgId = "SSO_ORG_ID"; beforeEach(async () => { + accountService = mockAccountServiceWith(userId); + masterPasswordService = new FakeMasterPasswordService(); + cryptoService = mock(); apiService = mock(); tokenService = mock(); @@ -83,6 +93,8 @@ describe("SsoLoginStrategy", () => { ssoLoginStrategy = new SsoLoginStrategy( null, + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -130,7 +142,7 @@ describe("SsoLoginStrategy", () => { await ssoLoginStrategy.logIn(credentials); - expect(cryptoService.setMasterKey).not.toHaveBeenCalled(); + expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled(); expect(cryptoService.setUserKey).not.toHaveBeenCalled(); expect(cryptoService.setPrivateKey).not.toHaveBeenCalled(); }); @@ -289,7 +301,7 @@ describe("SsoLoginStrategy", () => { id: "1", privateKey: "PRIVATE" as any, } as AdminAuthRequestStorable; - stateService.getAdminAuthRequest.mockResolvedValue( + authRequestService.getAdminAuthRequest.mockResolvedValue( new AdminAuthRequestStorable(adminAuthRequest), ); }); @@ -352,7 +364,7 @@ describe("SsoLoginStrategy", () => { await ssoLoginStrategy.logIn(credentials); - expect(stateService.setAdminAuthRequest).toHaveBeenCalledWith(null); + expect(authRequestService.clearAdminAuthRequest).toHaveBeenCalled(); expect( authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash, ).not.toHaveBeenCalled(); @@ -395,7 +407,7 @@ describe("SsoLoginStrategy", () => { ) as MasterKey; apiService.postIdentityToken.mockResolvedValue(tokenResponse); - cryptoService.getMasterKey.mockResolvedValue(masterKey); + masterPasswordService.masterKeySubject.next(masterKey); await ssoLoginStrategy.logIn(credentials); @@ -422,7 +434,7 @@ describe("SsoLoginStrategy", () => { ) as MasterKey; apiService.postIdentityToken.mockResolvedValue(tokenResponse); - cryptoService.getMasterKey.mockResolvedValue(masterKey); + masterPasswordService.masterKeySubject.next(masterKey); cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); await ssoLoginStrategy.logIn(credentials); @@ -446,7 +458,7 @@ describe("SsoLoginStrategy", () => { ) as MasterKey; apiService.postIdentityToken.mockResolvedValue(tokenResponse); - cryptoService.getMasterKey.mockResolvedValue(masterKey); + masterPasswordService.masterKeySubject.next(masterKey); await ssoLoginStrategy.logIn(credentials); @@ -473,7 +485,7 @@ describe("SsoLoginStrategy", () => { ) as MasterKey; apiService.postIdentityToken.mockResolvedValue(tokenResponse); - cryptoService.getMasterKey.mockResolvedValue(masterKey); + masterPasswordService.masterKeySubject.next(masterKey); cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); await ssoLoginStrategy.logIn(credentials); 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 7745104bd1..d8efd78984 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.ts @@ -1,9 +1,11 @@ -import { Observable, map, BehaviorSubject } from "rxjs"; +import { firstValueFrom, Observable, map, BehaviorSubject } from "rxjs"; import { Jsonify } from "type-fest"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; @@ -79,6 +81,8 @@ export class SsoLoginStrategy extends LoginStrategy { constructor( data: SsoLoginStrategyData, + accountService: AccountService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, cryptoService: CryptoService, apiService: ApiService, tokenService: TokenService, @@ -96,6 +100,8 @@ export class SsoLoginStrategy extends LoginStrategy { billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -138,7 +144,11 @@ export class SsoLoginStrategy extends LoginStrategy { // Auth guard currently handles redirects for this. if (ssoAuthResult.forcePasswordReset == ForceSetPasswordReason.AdminForcePasswordReset) { - await this.stateService.setForceSetPasswordReason(ssoAuthResult.forcePasswordReset); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setForceSetPasswordReason( + ssoAuthResult.forcePasswordReset, + userId, + ); } this.cache.next({ @@ -206,7 +216,10 @@ export class SsoLoginStrategy extends LoginStrategy { // TODO: future passkey login strategy will need to support setting user key (decrypting via TDE or admin approval request) // so might be worth moving this logic to a common place (base login strategy or a separate service?) - protected override async setUserKey(tokenResponse: IdentityTokenResponse): Promise { + protected override async setUserKey( + tokenResponse: IdentityTokenResponse, + userId: UserId, + ): Promise { const masterKeyEncryptedUserKey = tokenResponse.key; // Note: masterKeyEncryptedUserKey is undefined for SSO JIT provisioned users @@ -222,7 +235,7 @@ export class SsoLoginStrategy extends LoginStrategy { // Note: TDE and key connector are mutually exclusive if (userDecryptionOptions?.trustedDeviceOption) { - await this.trySetUserKeyWithApprovedAdminRequestIfExists(); + await this.trySetUserKeyWithApprovedAdminRequestIfExists(userId); const hasUserKey = await this.cryptoService.hasUserKey(); @@ -242,9 +255,9 @@ export class SsoLoginStrategy extends LoginStrategy { // is responsible for deriving master key from MP entry and then decrypting the user key } - private async trySetUserKeyWithApprovedAdminRequestIfExists(): Promise { + private async trySetUserKeyWithApprovedAdminRequestIfExists(userId: UserId): Promise { // At this point a user could have an admin auth request that has been approved - const adminAuthReqStorable = await this.stateService.getAdminAuthRequest(); + const adminAuthReqStorable = await this.authRequestService.getAdminAuthRequest(userId); if (!adminAuthReqStorable) { return; @@ -258,7 +271,7 @@ export class SsoLoginStrategy extends LoginStrategy { } catch (error) { if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) { // if we get a 404, it means the auth request has been deleted so clear it from storage - await this.stateService.setAdminAuthRequest(null); + await this.authRequestService.clearAdminAuthRequest(userId); } // Always return on an error here as we don't want to block the user from logging in @@ -285,12 +298,11 @@ 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 - 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 - await this.stateService.setAdminAuthRequest(null); + await this.authRequestService.clearAdminAuthRequest(userId); this.platformUtilsService.showToast("success", null, this.i18nService.t("loginApproved")); } @@ -323,7 +335,8 @@ export class SsoLoginStrategy extends LoginStrategy { } private async trySetUserKeyWithMasterKey(): Promise { - const masterKey = await this.cryptoService.getMasterKey(); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); // There is a scenario in which the master key is not set here. That will occur if the user // has a master password and is using Key Connector. In that case, we cannot set the master key diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts index 02aed305a4..5e7d7985b1 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.spec.ts @@ -5,6 +5,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; @@ -19,7 +20,9 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { UserId } from "@bitwarden/common/types/guid"; import { UserKey, MasterKey } from "@bitwarden/common/types/key"; import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction"; @@ -30,6 +33,8 @@ import { UserApiLoginStrategy, UserApiLoginStrategyData } from "./user-api-login describe("UserApiLoginStrategy", () => { let cache: UserApiLoginStrategyData; + let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; let cryptoService: MockProxy; let apiService: MockProxy; @@ -48,12 +53,16 @@ describe("UserApiLoginStrategy", () => { let apiLogInStrategy: UserApiLoginStrategy; let credentials: UserApiLoginCredentials; + const userId = Utils.newGuid() as UserId; const deviceId = Utils.newGuid(); const keyConnectorUrl = "KEY_CONNECTOR_URL"; const apiClientId = "API_CLIENT_ID"; const apiClientSecret = "API_CLIENT_SECRET"; beforeEach(async () => { + accountService = mockAccountServiceWith(userId); + masterPasswordService = new FakeMasterPasswordService(); + cryptoService = mock(); apiService = mock(); tokenService = mock(); @@ -74,6 +83,8 @@ describe("UserApiLoginStrategy", () => { apiLogInStrategy = new UserApiLoginStrategy( cache, + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -172,7 +183,7 @@ describe("UserApiLoginStrategy", () => { environmentService.environment$ = new BehaviorSubject(env); apiService.postIdentityToken.mockResolvedValue(tokenResponse); - cryptoService.getMasterKey.mockResolvedValue(masterKey); + masterPasswordService.masterKeySubject.next(masterKey); cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey); await apiLogInStrategy.logIn(credentials); diff --git a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts index 2af666f95c..4a0d005b1c 100644 --- a/libs/auth/src/common/login-strategies/user-api-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/user-api-login.strategy.ts @@ -2,7 +2,9 @@ import { firstValueFrom, BehaviorSubject } from "rxjs"; import { Jsonify } from "type-fest"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { UserApiTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/user-api-token.request"; @@ -16,6 +18,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 { UserApiLoginCredentials } from "../models/domain/login-credentials"; @@ -39,6 +42,8 @@ export class UserApiLoginStrategy extends LoginStrategy { constructor( data: UserApiLoginStrategyData, + accountService: AccountService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, cryptoService: CryptoService, apiService: ApiService, tokenService: TokenService, @@ -54,6 +59,8 @@ export class UserApiLoginStrategy extends LoginStrategy { billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -91,11 +98,15 @@ export class UserApiLoginStrategy extends LoginStrategy { } } - protected override async setUserKey(response: IdentityTokenResponse): Promise { + protected override async setUserKey( + response: IdentityTokenResponse, + userId: UserId, + ): Promise { await this.cryptoService.setMasterKeyEncryptedUserKey(response.key); if (response.apiUseKeyConnector) { - const masterKey = await this.cryptoService.getMasterKey(); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); if (masterKey) { const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey); await this.cryptoService.setUserKey(userKey); @@ -109,8 +120,8 @@ export class UserApiLoginStrategy extends LoginStrategy { ); } - protected async saveAccountInformation(tokenResponse: IdentityTokenResponse) { - await super.saveAccountInformation(tokenResponse); + protected async saveAccountInformation(tokenResponse: IdentityTokenResponse): Promise { + const userId = await super.saveAccountInformation(tokenResponse); const vaultTimeout = await this.stateService.getVaultTimeout(); const vaultTimeoutAction = await this.stateService.getVaultTimeoutAction(); @@ -127,6 +138,7 @@ export class UserApiLoginStrategy extends LoginStrategy { vaultTimeoutAction as VaultTimeoutAction, vaultTimeout, ); + return userId; } exportCache(): CacheData { diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts index edc1441361..1d96921286 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.spec.ts @@ -6,6 +6,7 @@ import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; @@ -16,6 +17,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { FakeAccountService } from "@bitwarden/common/spec"; import { PrfKey, UserKey } from "@bitwarden/common/types/key"; import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction"; @@ -26,6 +28,8 @@ import { WebAuthnLoginStrategy, WebAuthnLoginStrategyData } from "./webauthn-log describe("WebAuthnLoginStrategy", () => { let cache: WebAuthnLoginStrategyData; + let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; let cryptoService!: MockProxy; let apiService!: MockProxy; @@ -63,6 +67,9 @@ describe("WebAuthnLoginStrategy", () => { beforeEach(() => { jest.clearAllMocks(); + accountService = new FakeAccountService(null); + masterPasswordService = new FakeMasterPasswordService(); + cryptoService = mock(); apiService = mock(); tokenService = mock(); @@ -81,6 +88,8 @@ describe("WebAuthnLoginStrategy", () => { webAuthnLoginStrategy = new WebAuthnLoginStrategy( cache, + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -207,7 +216,7 @@ describe("WebAuthnLoginStrategy", () => { expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(idTokenResponse.privateKey); // Master key and private key should not be set - expect(cryptoService.setMasterKey).not.toHaveBeenCalled(); + expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled(); }); it("does not try to set the user key when prfKey is missing", async () => { diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts index a8e67597b8..8a62a8fb3c 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts @@ -2,6 +2,8 @@ import { BehaviorSubject } from "rxjs"; import { Jsonify } from "type-fest"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; @@ -15,6 +17,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 { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions"; @@ -41,6 +44,8 @@ export class WebAuthnLoginStrategy extends LoginStrategy { constructor( data: WebAuthnLoginStrategyData, + accountService: AccountService, + masterPasswordService: InternalMasterPasswordServiceAbstraction, cryptoService: CryptoService, apiService: ApiService, tokenService: TokenService, @@ -54,6 +59,8 @@ export class WebAuthnLoginStrategy extends LoginStrategy { billingAccountProfileStateService: BillingAccountProfileStateService, ) { super( + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -92,7 +99,7 @@ export class WebAuthnLoginStrategy extends LoginStrategy { return Promise.resolve(); } - protected override async setUserKey(idTokenResponse: IdentityTokenResponse) { + protected override async setUserKey(idTokenResponse: IdentityTokenResponse, userId: UserId) { const masterKeyEncryptedUserKey = idTokenResponse.key; if (masterKeyEncryptedUserKey) { 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 80d00b2a01..5907048684 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,13 +2,16 @@ 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 { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; 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"; 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 { StateProvider } from "@bitwarden/common/platform/state"; +import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { AuthRequestService } from "./auth-request.service"; @@ -16,17 +19,29 @@ import { AuthRequestService } from "./auth-request.service"; describe("AuthRequestService", () => { let sut: AuthRequestService; + const stateProvider = mock(); + let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; const appIdService = mock(); const cryptoService = mock(); const apiService = mock(); - const stateService = mock(); let mockPrivateKey: Uint8Array; + const mockUserId = Utils.newGuid() as UserId; beforeEach(() => { jest.clearAllMocks(); + accountService = mockAccountServiceWith(mockUserId); + masterPasswordService = new FakeMasterPasswordService(); - sut = new AuthRequestService(appIdService, cryptoService, apiService, stateService); + sut = new AuthRequestService( + appIdService, + accountService, + masterPasswordService, + cryptoService, + apiService, + stateProvider, + ); mockPrivateKey = new Uint8Array(64); }); @@ -47,6 +62,31 @@ describe("AuthRequestService", () => { }); }); + describe("AcceptAuthRequests", () => { + it("returns an error when userId isn't provided", async () => { + await expect(sut.getAcceptAuthRequests(undefined)).rejects.toThrow("User ID is required"); + await expect(sut.setAcceptAuthRequests(true, undefined)).rejects.toThrow( + "User ID is required", + ); + }); + }); + + describe("AdminAuthRequest", () => { + it("returns an error when userId isn't provided", async () => { + await expect(sut.getAdminAuthRequest(undefined)).rejects.toThrow("User ID is required"); + await expect(sut.setAdminAuthRequest(undefined, undefined)).rejects.toThrow( + "User ID is required", + ); + await expect(sut.clearAdminAuthRequest(undefined)).rejects.toThrow("User ID is required"); + }); + + it("does not allow clearing from setAdminAuthRequest", async () => { + await expect(sut.setAdminAuthRequest(null, "USER_ID" as UserId)).rejects.toThrow( + "Auth request is required", + ); + }); + }); + describe("approveOrDenyAuthRequest", () => { beforeEach(() => { cryptoService.rsaEncrypt.mockResolvedValue({ @@ -67,8 +107,8 @@ describe("AuthRequestService", () => { }); it("should use the master key and hash if they exist", async () => { - cryptoService.getMasterKey.mockResolvedValueOnce({ encKey: new Uint8Array(64) } as MasterKey); - stateService.getKeyHash.mockResolvedValueOnce("KEY_HASH"); + masterPasswordService.masterKeySubject.next({ encKey: new Uint8Array(64) } as MasterKey); + masterPasswordService.masterKeyHashSubject.next("MASTER_KEY_HASH"); await sut.approveOrDenyAuthRequest( true, @@ -130,8 +170,8 @@ describe("AuthRequestService", () => { masterKeyHash: mockDecryptedMasterKeyHash, }); - cryptoService.setMasterKey.mockResolvedValueOnce(undefined); - cryptoService.setMasterKeyHash.mockResolvedValueOnce(undefined); + masterPasswordService.masterKeySubject.next(undefined); + masterPasswordService.masterKeyHashSubject.next(undefined); cryptoService.decryptUserKeyWithMasterKey.mockResolvedValueOnce(mockDecryptedUserKey); cryptoService.setUserKey.mockResolvedValueOnce(undefined); @@ -144,10 +184,18 @@ describe("AuthRequestService", () => { mockAuthReqResponse.masterPasswordHash, mockPrivateKey, ); - expect(cryptoService.setMasterKey).toBeCalledWith(mockDecryptedMasterKey); - expect(cryptoService.setMasterKeyHash).toBeCalledWith(mockDecryptedMasterKeyHash); - expect(cryptoService.decryptUserKeyWithMasterKey).toBeCalledWith(mockDecryptedMasterKey); - expect(cryptoService.setUserKey).toBeCalledWith(mockDecryptedUserKey); + expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith( + mockDecryptedMasterKey, + mockUserId, + ); + expect(masterPasswordService.mock.setMasterKeyHash).toHaveBeenCalledWith( + mockDecryptedMasterKeyHash, + mockUserId, + ); + expect(cryptoService.decryptUserKeyWithMasterKey).toHaveBeenCalledWith( + mockDecryptedMasterKey, + ); + expect(cryptoService.setUserKey).toHaveBeenCalledWith(mockDecryptedUserKey); }); }); 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 eb39659f53..062a10af14 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 @@ -1,31 +1,119 @@ -import { Observable, Subject } from "rxjs"; +import { Observable, Subject, firstValueFrom } from "rxjs"; +import { Jsonify } from "type-fest"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; +import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/admin-auth-req-storable"; import { PasswordlessAuthRequest } from "@bitwarden/common/auth/models/request/passwordless-auth.request"; 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"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { + AUTH_REQUEST_DISK_LOCAL, + StateProvider, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/common/types/guid"; import { MasterKey, UserKey } from "@bitwarden/common/types/key"; import { AuthRequestServiceAbstraction } from "../../abstractions/auth-request.service.abstraction"; +/** + * Disk-local to maintain consistency between tabs (even though + * approvals are currently only available on desktop). We don't + * want to clear this on logout as it's a user preference. + */ +export const ACCEPT_AUTH_REQUESTS_KEY = new UserKeyDefinition( + AUTH_REQUEST_DISK_LOCAL, + "acceptAuthRequests", + { + deserializer: (value) => value ?? false, + clearOn: [], + }, +); + +/** + * Disk-local to maintain consistency between tabs. We don't want to + * clear this on logout since admin auth requests are long-lived. + */ +export const ADMIN_AUTH_REQUEST_KEY = new UserKeyDefinition>( + AUTH_REQUEST_DISK_LOCAL, + "adminAuthRequest", + { + deserializer: (value) => value, + clearOn: [], + }, +); + export class AuthRequestService implements AuthRequestServiceAbstraction { private authRequestPushNotificationSubject = new Subject(); authRequestPushNotification$: Observable; constructor( private appIdService: AppIdService, + private accountService: AccountService, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, private cryptoService: CryptoService, private apiService: ApiService, - private stateService: StateService, + private stateProvider: StateProvider, ) { this.authRequestPushNotification$ = this.authRequestPushNotificationSubject.asObservable(); } + async getAcceptAuthRequests(userId: UserId): Promise { + if (userId == null) { + throw new Error("User ID is required"); + } + + const value = await firstValueFrom( + this.stateProvider.getUser(userId, ACCEPT_AUTH_REQUESTS_KEY).state$, + ); + return value; + } + + async setAcceptAuthRequests(accept: boolean, userId: UserId): Promise { + if (userId == null) { + throw new Error("User ID is required"); + } + + await this.stateProvider.setUserState(ACCEPT_AUTH_REQUESTS_KEY, accept, userId); + } + + async getAdminAuthRequest(userId: UserId): Promise { + if (userId == null) { + throw new Error("User ID is required"); + } + + const authRequestSerialized = await firstValueFrom( + this.stateProvider.getUser(userId, ADMIN_AUTH_REQUEST_KEY).state$, + ); + const adminAuthRequestStorable = AdminAuthRequestStorable.fromJSON(authRequestSerialized); + return adminAuthRequestStorable; + } + + async setAdminAuthRequest(authRequest: AdminAuthRequestStorable, userId: UserId): Promise { + if (userId == null) { + throw new Error("User ID is required"); + } + if (authRequest == null) { + throw new Error("Auth request is required"); + } + + await this.stateProvider.setUserState(ADMIN_AUTH_REQUEST_KEY, authRequest.toJSON(), userId); + } + + async clearAdminAuthRequest(userId: UserId): Promise { + if (userId == null) { + throw new Error("User ID is required"); + } + + await this.stateProvider.setUserState(ADMIN_AUTH_REQUEST_KEY, null, userId); + } + async approveOrDenyAuthRequest( approve: boolean, authRequest: AuthRequestResponse, @@ -38,8 +126,9 @@ export class AuthRequestService implements AuthRequestServiceAbstraction { } const pubKey = Utils.fromB64ToArray(authRequest.publicKey); - const masterKey = await this.cryptoService.getMasterKey(); - const masterKeyHash = await this.stateService.getKeyHash(); + const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; + const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); + const masterKeyHash = await firstValueFrom(this.masterPasswordService.masterKeyHash$(userId)); let encryptedMasterKeyHash; let keyToEncrypt; @@ -92,8 +181,9 @@ export class AuthRequestService implements AuthRequestServiceAbstraction { const userKey = await this.cryptoService.decryptUserKeyWithMasterKey(masterKey); // Set masterKey + masterKeyHash in state after decryption (in case decryption fails) - await this.cryptoService.setMasterKey(masterKey); - await this.cryptoService.setMasterKeyHash(masterKeyHash); + const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; + await this.masterPasswordService.setMasterKey(masterKey, userId); + await this.masterPasswordService.setMasterKeyHash(masterKeyHash, userId); await this.cryptoService.setUserKey(userKey); } diff --git a/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts b/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts index 5714f8669f..cbe42f3236 100644 --- a/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts +++ b/libs/auth/src/common/services/login-strategies/login-strategy.service.spec.ts @@ -11,6 +11,7 @@ import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response"; +import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; @@ -23,8 +24,14 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { KdfType } from "@bitwarden/common/platform/enums"; import { TaskSchedulerService } from "@bitwarden/common/platform/services/task-scheduler.service"; -import { FakeGlobalState, FakeGlobalStateProvider } from "@bitwarden/common/spec"; +import { + FakeAccountService, + FakeGlobalState, + FakeGlobalStateProvider, + mockAccountServiceWith, +} from "@bitwarden/common/spec"; import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength"; +import { UserId } from "@bitwarden/common/types/guid"; import { AuthRequestServiceAbstraction, @@ -39,6 +46,8 @@ import { CACHE_EXPIRATION_KEY } from "./login-strategy.state"; describe("LoginStrategyService", () => { let sut: LoginStrategyService; + let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; let cryptoService: MockProxy; let apiService: MockProxy; let tokenService: MockProxy; @@ -63,7 +72,11 @@ describe("LoginStrategyService", () => { let stateProvider: FakeGlobalStateProvider; let loginStrategyCacheExpirationState: FakeGlobalState; + const userId = "USER_ID" as UserId; + beforeEach(() => { + accountService = mockAccountServiceWith(userId); + masterPasswordService = new FakeMasterPasswordService(); cryptoService = mock(); apiService = mock(); tokenService = mock(); @@ -87,6 +100,8 @@ describe("LoginStrategyService", () => { taskSchedulerService = mock(); sut = new LoginStrategyService( + accountService, + masterPasswordService, cryptoService, apiService, tokenService, diff --git a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts index 29fc5fa5b1..cb2876787b 100644 --- a/libs/auth/src/common/services/login-strategies/login-strategy.service.ts +++ b/libs/auth/src/common/services/login-strategies/login-strategy.service.ts @@ -9,8 +9,10 @@ import { import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } 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 { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type"; @@ -83,6 +85,8 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { currentAuthType$: Observable; constructor( + protected accountService: AccountService, + protected masterPasswordService: InternalMasterPasswordServiceAbstraction, protected cryptoService: CryptoService, protected apiService: ApiService, protected tokenService: TokenService, @@ -260,7 +264,8 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { ): Promise { const pubKey = Utils.fromB64ToArray(key); - const masterKey = await this.cryptoService.getMasterKey(); + const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; + const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); let keyToEncrypt; let encryptedMasterKeyHash = null; @@ -269,7 +274,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { // Only encrypt the master password hash if masterKey exists as // we won't have a masterKeyHash without a masterKey - const masterKeyHash = await this.stateService.getKeyHash(); + const masterKeyHash = await firstValueFrom(this.masterPasswordService.masterKeyHash$(userId)); if (masterKeyHash != null) { encryptedMasterKeyHash = await this.cryptoService.rsaEncrypt( Utils.fromUtf8ToArray(masterKeyHash), @@ -343,6 +348,8 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { case AuthenticationType.Password: return new PasswordLoginStrategy( data?.password, + this.accountService, + this.masterPasswordService, this.cryptoService, this.apiService, this.tokenService, @@ -361,6 +368,8 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { case AuthenticationType.Sso: return new SsoLoginStrategy( data?.sso, + this.accountService, + this.masterPasswordService, this.cryptoService, this.apiService, this.tokenService, @@ -380,6 +389,8 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { case AuthenticationType.UserApiKey: return new UserApiLoginStrategy( data?.userApiKey, + this.accountService, + this.masterPasswordService, this.cryptoService, this.apiService, this.tokenService, @@ -397,6 +408,8 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { case AuthenticationType.AuthRequest: return new AuthRequestLoginStrategy( data?.authRequest, + this.accountService, + this.masterPasswordService, this.cryptoService, this.apiService, this.tokenService, @@ -413,6 +426,8 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction { case AuthenticationType.WebAuthn: return new WebAuthnLoginStrategy( data?.webAuthn, + this.accountService, + this.masterPasswordService, this.cryptoService, this.apiService, this.tokenService, diff --git a/libs/auth/src/common/services/user-decryption-options/user-decryption-options.service.spec.ts b/libs/auth/src/common/services/user-decryption-options/user-decryption-options.service.spec.ts index e8bb1b38ce..16479f19ea 100644 --- a/libs/auth/src/common/services/user-decryption-options/user-decryption-options.service.spec.ts +++ b/libs/auth/src/common/services/user-decryption-options/user-decryption-options.service.spec.ts @@ -1,6 +1,5 @@ import { firstValueFrom } from "rxjs"; -import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { FakeAccountService, @@ -66,7 +65,6 @@ describe("UserDecryptionOptionsService", () => { await fakeAccountService.addAccount(givenUser, { name: "Test User 1", email: "test1@email.com", - status: AuthenticationStatus.Locked, }); await fakeStateProvider.setUserState( USER_DECRYPTION_OPTIONS, diff --git a/libs/common/spec/fake-account-service.ts b/libs/common/spec/fake-account-service.ts index 2f33d9cf02..a8b09b7417 100644 --- a/libs/common/spec/fake-account-service.ts +++ b/libs/common/spec/fake-account-service.ts @@ -1,8 +1,7 @@ import { mock } from "jest-mock-extended"; -import { Observable, ReplaySubject } from "rxjs"; +import { ReplaySubject } from "rxjs"; import { AccountInfo, AccountService } from "../src/auth/abstractions/account.service"; -import { AuthenticationStatus } from "../src/auth/enums/authentication-status"; import { UserId } from "../src/types/guid"; export function mockAccountServiceWith( @@ -14,7 +13,6 @@ export function mockAccountServiceWith( ...{ name: "name", email: "email", - status: AuthenticationStatus.Locked, }, }; const service = new FakeAccountService({ [userId]: fullInfo }); @@ -32,14 +30,8 @@ export class FakeAccountService implements AccountService { get activeUserId() { return this._activeUserId; } - get accounts$() { - return this.accountsSubject.asObservable(); - } - get activeAccount$() { - return this.activeAccountSubject.asObservable(); - } - accountLock$: Observable; - accountLogout$: Observable; + accounts$ = this.accountsSubject.asObservable(); + activeAccount$ = this.activeAccountSubject.asObservable(); constructor(initialData: Record) { this.accountsSubject.next(initialData); @@ -61,14 +53,6 @@ export class FakeAccountService implements AccountService { await this.mock.setAccountEmail(userId, email); } - async setAccountStatus(userId: UserId, status: AuthenticationStatus): Promise { - await this.mock.setAccountStatus(userId, status); - } - - async setMaxAccountStatus(userId: UserId, maxStatus: AuthenticationStatus): Promise { - await this.mock.setMaxAccountStatus(userId, maxStatus); - } - async switchAccount(userId: UserId): Promise { const next = userId == null ? null : { id: userId, ...this.accountsSubject["_buffer"]?.[0]?.[userId] }; diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 20ed3216a5..6962a44268 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -220,7 +220,7 @@ export abstract class ApiService { putMoveCiphers: (request: CipherBulkMoveRequest) => Promise; putShareCipher: (id: string, request: CipherShareRequest) => Promise; putShareCiphers: (request: CipherBulkShareRequest) => Promise; - putCipherCollections: (id: string, request: CipherCollectionsRequest) => Promise; + putCipherCollections: (id: string, request: CipherCollectionsRequest) => Promise; putCipherCollectionsAdmin: (id: string, request: CipherCollectionsRequest) => Promise; postPurgeCiphers: (request: SecretVerificationRequest, organizationId?: string) => Promise; putDeleteCipher: (id: string) => Promise; diff --git a/libs/common/src/abstractions/search.service.ts b/libs/common/src/abstractions/search.service.ts index 97a12c8315..dfcf2c5d07 100644 --- a/libs/common/src/abstractions/search.service.ts +++ b/libs/common/src/abstractions/search.service.ts @@ -1,11 +1,15 @@ +import { Observable } from "rxjs"; + import { SendView } from "../tools/send/models/view/send.view"; +import { IndexedEntityId } from "../types/guid"; import { CipherView } from "../vault/models/view/cipher.view"; export abstract class SearchService { - indexedEntityId?: string = null; - clearIndex: () => void; - isSearchable: (query: string) => boolean; - indexCiphers: (ciphersToIndex: CipherView[], indexedEntityGuid?: string) => void; + indexedEntityId$: Observable; + + clearIndex: () => Promise; + isSearchable: (query: string) => Promise; + indexCiphers: (ciphersToIndex: CipherView[], indexedEntityGuid?: string) => Promise; searchCiphers: ( query: string, filter?: ((cipher: CipherView) => boolean) | ((cipher: CipherView) => boolean)[], diff --git a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts index 7f1a40d140..66a05cf613 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization-api.service.abstraction.ts @@ -3,9 +3,9 @@ import { OrganizationSsoRequest } from "../../../auth/models/request/organizatio import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request"; import { ApiKeyResponse } from "../../../auth/models/response/api-key.response"; import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response"; +import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request"; import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request"; -import { OrganizationTaxInfoUpdateRequest } from "../../../billing/models/request/organization-tax-info-update.request"; import { PaymentRequest } from "../../../billing/models/request/payment.request"; import { SecretsManagerSubscribeRequest } from "../../../billing/models/request/sm-subscribe.request"; import { BillingResponse } from "../../../billing/models/response/billing.response"; @@ -63,7 +63,7 @@ export class OrganizationApiServiceAbstraction { ) => Promise>; rotateApiKey: (id: string, request: OrganizationApiKeyRequest) => Promise; getTaxInfo: (id: string) => Promise; - updateTaxInfo: (id: string, request: OrganizationTaxInfoUpdateRequest) => Promise; + updateTaxInfo: (id: string, request: ExpandedTaxInfoUpdateRequest) => Promise; getKeys: (id: string) => Promise; updateKeys: (id: string, request: OrganizationKeysRequest) => Promise; getSso: (id: string) => Promise; diff --git a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts index a1ae64a885..fefcac3a57 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts @@ -121,7 +121,11 @@ export abstract class OrganizationService { get$: (id: string) => Observable; get: (id: string) => Promise; getAll: (userId?: string) => Promise; - // + + /** + * Publishes state for all organizations for the given user id or the active user. + */ + getAll$: (userId?: UserId) => Observable; } /** diff --git a/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts b/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts index 61eb943f1d..7dc664869c 100644 --- a/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts +++ b/libs/common/src/admin-console/models/request/provider/provider-setup.request.ts @@ -1,7 +1,10 @@ +import { ExpandedTaxInfoUpdateRequest } from "../../../../billing/models/request/expanded-tax-info-update.request"; + export class ProviderSetupRequest { name: string; businessName: string; billingEmail: string; token: string; key: string; + taxInfo: ExpandedTaxInfoUpdateRequest; } diff --git a/libs/common/src/admin-console/services/organization-domain/requests/organization-domain.request.ts b/libs/common/src/admin-console/services/organization-domain/requests/organization-domain.request.ts index fb515e3cbc..c316a1d27c 100644 --- a/libs/common/src/admin-console/services/organization-domain/requests/organization-domain.request.ts +++ b/libs/common/src/admin-console/services/organization-domain/requests/organization-domain.request.ts @@ -1,9 +1,7 @@ export class OrganizationDomainRequest { - txt: string; domainName: string; - constructor(txt: string, domainName: string) { - this.txt = txt; + constructor(domainName: string) { this.domainName = domainName; } } diff --git a/libs/common/src/admin-console/services/organization/organization-api.service.ts b/libs/common/src/admin-console/services/organization/organization-api.service.ts index 883bf35260..262232a964 100644 --- a/libs/common/src/admin-console/services/organization/organization-api.service.ts +++ b/libs/common/src/admin-console/services/organization/organization-api.service.ts @@ -4,9 +4,9 @@ import { OrganizationSsoRequest } from "../../../auth/models/request/organizatio import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request"; import { ApiKeyResponse } from "../../../auth/models/response/api-key.response"; import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response"; +import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request"; import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request"; import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request"; -import { OrganizationTaxInfoUpdateRequest } from "../../../billing/models/request/organization-tax-info-update.request"; import { PaymentRequest } from "../../../billing/models/request/payment.request"; import { SecretsManagerSubscribeRequest } from "../../../billing/models/request/sm-subscribe.request"; import { BillingResponse } from "../../../billing/models/response/billing.response"; @@ -257,7 +257,7 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction return new TaxInfoResponse(r); } - async updateTaxInfo(id: string, request: OrganizationTaxInfoUpdateRequest): Promise { + async updateTaxInfo(id: string, request: ExpandedTaxInfoUpdateRequest): Promise { // Can't broadcast anything because the response doesn't have content return this.apiService.send("PUT", "/organizations/" + id + "/tax", request, true, false); } diff --git a/libs/common/src/admin-console/services/organization/organization.service.ts b/libs/common/src/admin-console/services/organization/organization.service.ts index 411850fe30..7013863c5c 100644 --- a/libs/common/src/admin-console/services/organization/organization.service.ts +++ b/libs/common/src/admin-console/services/organization/organization.service.ts @@ -73,6 +73,10 @@ export class OrganizationService implements InternalOrganizationServiceAbstracti return this.organizations$.pipe(mapToSingleOrganization(id)); } + getAll$(userId?: UserId): Observable { + return this.getOrganizationsFromState$(userId); + } + async getAll(userId?: string): Promise { return await firstValueFrom(this.getOrganizationsFromState$(userId as UserId)); } diff --git a/libs/common/src/admin-console/services/policy/policy.service.spec.ts b/libs/common/src/admin-console/services/policy/policy.service.spec.ts index a1633d29ff..88264d1c3b 100644 --- a/libs/common/src/admin-console/services/policy/policy.service.spec.ts +++ b/libs/common/src/admin-console/services/policy/policy.service.spec.ts @@ -32,7 +32,8 @@ describe("PolicyService", () => { organizationService = mock(); activeUserState = stateProvider.activeUser.getFake(POLICIES); - organizationService.organizations$ = of([ + + const organizations$ = of([ // User organization("org1", true, true, OrganizationUserStatusType.Confirmed, false), // Owner @@ -50,8 +51,14 @@ describe("PolicyService", () => { organization("org4", true, true, OrganizationUserStatusType.Confirmed, false), // Another User organization("org5", true, true, OrganizationUserStatusType.Confirmed, false), + // Can manage policies + organization("org6", true, true, OrganizationUserStatusType.Confirmed, true), ]); + organizationService.organizations$ = organizations$; + + organizationService.getAll$.mockReturnValue(organizations$); + policyService = new PolicyService(stateProvider, organizationService); }); @@ -254,6 +261,22 @@ describe("PolicyService", () => { expect(result).toBeNull(); }); + it.each([ + ["owners", "org2"], + ["administrators", "org6"], + ])("returns the password generator policy for %s", async (_, organization) => { + activeUserState.nextState( + arrayToRecord([ + policyData("policy1", "org1", PolicyType.ActivateAutofill, false), + policyData("policy2", organization, PolicyType.PasswordGenerator, true), + ]), + ); + + const result = await firstValueFrom(policyService.get$(PolicyType.PasswordGenerator)); + + expect(result).toBeTruthy(); + }); + it("does not return policies for organizations that do not use policies", async () => { activeUserState.nextState( arrayToRecord([ diff --git a/libs/common/src/admin-console/services/policy/policy.service.ts b/libs/common/src/admin-console/services/policy/policy.service.ts index 0cbc7204de..e36902cbf9 100644 --- a/libs/common/src/admin-console/services/policy/policy.service.ts +++ b/libs/common/src/admin-console/services/policy/policy.service.ts @@ -51,7 +51,7 @@ export class PolicyService implements InternalPolicyServiceAbstraction { map((policies) => policies.filter((p) => p.type === policyType)), ); - return combineLatest([filteredPolicies$, this.organizationService.organizations$]).pipe( + return combineLatest([filteredPolicies$, this.organizationService.getAll$(userId)]).pipe( map(([policies, organizations]) => this.enforcedPolicyFilter(policies, organizations)), ); } @@ -232,6 +232,9 @@ export class PolicyService implements InternalPolicyServiceAbstraction { case PolicyType.MaximumVaultTimeout: // Max Vault Timeout applies to everyone except owners return organization.isOwner; + case PolicyType.PasswordGenerator: + // password generation policy applies to everyone + return false; default: return organization.canManagePolicies; } diff --git a/libs/common/src/auth/abstractions/account.service.ts b/libs/common/src/auth/abstractions/account.service.ts index 4e2a462755..fa9ad36378 100644 --- a/libs/common/src/auth/abstractions/account.service.ts +++ b/libs/common/src/auth/abstractions/account.service.ts @@ -1,27 +1,23 @@ import { Observable } from "rxjs"; import { UserId } from "../../types/guid"; -import { AuthenticationStatus } from "../enums/authentication-status"; /** * Holds information about an account for use in the AccountService * if more information is added, be sure to update the equality method. */ export type AccountInfo = { - status: AuthenticationStatus; email: string; name: string | undefined; }; export function accountInfoEqual(a: AccountInfo, b: AccountInfo) { - return a?.status === b?.status && a?.email === b?.email && a?.name === b?.name; + return a?.email === b?.email && a?.name === b?.name; } export abstract class AccountService { accounts$: Observable>; activeAccount$: Observable<{ id: UserId | undefined } & AccountInfo>; - accountLock$: Observable; - accountLogout$: Observable; /** * Updates the `accounts$` observable with the new account data. * @param userId @@ -40,24 +36,6 @@ export abstract class AccountService { * @param email */ abstract setAccountEmail(userId: UserId, email: string): Promise; - /** - * Updates the `accounts$` observable with the new account status. - * Also emits the `accountLock$` or `accountLogout$` observable if the status is `Locked` or `LoggedOut` respectively. - * @param userId - * @param status - */ - abstract setAccountStatus(userId: UserId, status: AuthenticationStatus): Promise; - /** - * Updates the `accounts$` observable with the new account status if the current status is higher than the `maxStatus`. - * - * This method only downgrades status to the maximum value sent in, it will not increase authentication status. - * - * @example An account is transitioning from unlocked to logged out. If callbacks that set the status to locked occur - * after it is updated to logged out, the account will be in the incorrect state. - * @param userId The user id of the account to be updated. - * @param maxStatus The new status of the account. - */ - abstract setMaxAccountStatus(userId: UserId, maxStatus: AuthenticationStatus): Promise; /** * Updates the `activeAccount$` observable with the new active account. * @param userId diff --git a/libs/common/src/auth/abstractions/auth.service.ts b/libs/common/src/auth/abstractions/auth.service.ts index de08dbd4e9..36d5d219b2 100644 --- a/libs/common/src/auth/abstractions/auth.service.ts +++ b/libs/common/src/auth/abstractions/auth.service.ts @@ -6,6 +6,8 @@ import { AuthenticationStatus } from "../enums/authentication-status"; export abstract class AuthService { /** Authentication status for the active user */ abstract activeAccountStatus$: Observable; + /** Authentication status for all known users */ + abstract authStatuses$: Observable>; /** * Returns an observable authentication status for the given user id. * @note userId is a required parameter, null values will always return `AuthenticationStatus.LoggedOut` diff --git a/libs/common/src/auth/abstractions/master-password.service.abstraction.ts b/libs/common/src/auth/abstractions/master-password.service.abstraction.ts new file mode 100644 index 0000000000..b36c8bfaae --- /dev/null +++ b/libs/common/src/auth/abstractions/master-password.service.abstraction.ts @@ -0,0 +1,82 @@ +import { Observable } from "rxjs"; + +import { EncString } from "../../platform/models/domain/enc-string"; +import { UserId } from "../../types/guid"; +import { MasterKey } from "../../types/key"; +import { ForceSetPasswordReason } from "../models/domain/force-set-password-reason"; + +export abstract class MasterPasswordServiceAbstraction { + /** + * An observable that emits if the user is being forced to set a password on login and why. + * @param userId The user ID. + * @throws If the user ID is missing. + */ + abstract forceSetPasswordReason$: (userId: UserId) => Observable; + /** + * An observable that emits the master key for the user. + * @param userId The user ID. + * @throws If the user ID is missing. + */ + abstract masterKey$: (userId: UserId) => Observable; + /** + * An observable that emits the master key hash for the user. + * @param userId The user ID. + * @throws If the user ID is missing. + */ + abstract masterKeyHash$: (userId: UserId) => Observable; + /** + * Returns the master key encrypted user key for the user. + * @param userId The user ID. + * @throws If the user ID is missing. + */ + abstract getMasterKeyEncryptedUserKey: (userId: UserId) => Promise; +} + +export abstract class InternalMasterPasswordServiceAbstraction extends MasterPasswordServiceAbstraction { + /** + * Set the master key for the user. + * Note: Use {@link clearMasterKey} to clear the master key. + * @param masterKey The master key. + * @param userId The user ID. + * @throws If the user ID or master key is missing. + */ + abstract setMasterKey: (masterKey: MasterKey, userId: UserId) => Promise; + /** + * Clear the master key for the user. + * @param userId The user ID. + * @throws If the user ID is missing. + */ + abstract clearMasterKey: (userId: UserId) => Promise; + /** + * Set the master key hash for the user. + * Note: Use {@link clearMasterKeyHash} to clear the master key hash. + * @param masterKeyHash The master key hash. + * @param userId The user ID. + * @throws If the user ID or master key hash is missing. + */ + abstract setMasterKeyHash: (masterKeyHash: string, userId: UserId) => Promise; + /** + * Clear the master key hash for the user. + * @param userId The user ID. + * @throws If the user ID is missing. + */ + abstract clearMasterKeyHash: (userId: UserId) => Promise; + + /** + * Set the master key encrypted user key for the user. + * @param encryptedKey The master key encrypted user key. + * @param userId The user ID. + * @throws If the user ID or encrypted key is missing. + */ + abstract setMasterKeyEncryptedUserKey: (encryptedKey: EncString, userId: UserId) => Promise; + /** + * Set the force set password reason for the user. + * @param reason The reason the user is being forced to set a password. + * @param userId The user ID. + * @throws If the user ID or reason is missing. + */ + abstract setForceSetPasswordReason: ( + reason: ForceSetPasswordReason, + userId: UserId, + ) => Promise; +} diff --git a/libs/common/src/auth/models/domain/admin-auth-req-storable.ts b/libs/common/src/auth/models/domain/admin-auth-req-storable.ts index 1eae7eeab1..df0341ac16 100644 --- a/libs/common/src/auth/models/domain/admin-auth-req-storable.ts +++ b/libs/common/src/auth/models/domain/admin-auth-req-storable.ts @@ -1,11 +1,7 @@ +import { Jsonify } from "type-fest"; + import { Utils } from "../../../platform/misc/utils"; -// TODO: Tech Debt: potentially create a type Storage shape vs using a class here in the future -// type StorageShape { -// id: string; -// privateKey: string; -// } -// so we can get rid of the any type passed into fromJSON and coming out of ToJSON export class AdminAuthRequestStorable { id: string; privateKey: Uint8Array; @@ -23,7 +19,7 @@ export class AdminAuthRequestStorable { }; } - static fromJSON(obj: any): AdminAuthRequestStorable { + static fromJSON(obj: Jsonify): AdminAuthRequestStorable { if (obj == null) { return null; } diff --git a/libs/common/src/auth/services/account.service.spec.ts b/libs/common/src/auth/services/account.service.spec.ts index e4195365f4..a9cec82c51 100644 --- a/libs/common/src/auth/services/account.service.spec.ts +++ b/libs/common/src/auth/services/account.service.spec.ts @@ -8,7 +8,6 @@ import { LogService } from "../../platform/abstractions/log.service"; import { MessagingService } from "../../platform/abstractions/messaging.service"; import { UserId } from "../../types/guid"; import { AccountInfo } from "../abstractions/account.service"; -import { AuthenticationStatus } from "../enums/authentication-status"; import { ACCOUNT_ACCOUNTS, @@ -24,9 +23,7 @@ describe("accountService", () => { let accountsState: FakeGlobalState>; let activeAccountIdState: FakeGlobalState; const userId = "userId" as UserId; - function userInfo(status: AuthenticationStatus): AccountInfo { - return { status, email: "email", name: "name" }; - } + const userInfo = { email: "email", name: "name" }; beforeEach(() => { messagingService = mock(); @@ -50,61 +47,49 @@ describe("accountService", () => { expect(emissions).toEqual([undefined]); }); - it("should emit the active account and status", async () => { + it("should emit the active account", async () => { const emissions = trackEmissions(sut.activeAccount$); - accountsState.stateSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) }); + accountsState.stateSubject.next({ [userId]: userInfo }); activeAccountIdState.stateSubject.next(userId); expect(emissions).toEqual([ undefined, // initial value - { id: userId, ...userInfo(AuthenticationStatus.Unlocked) }, - ]); - }); - - it("should update the status if the account status changes", async () => { - accountsState.stateSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) }); - activeAccountIdState.stateSubject.next(userId); - const emissions = trackEmissions(sut.activeAccount$); - accountsState.stateSubject.next({ [userId]: userInfo(AuthenticationStatus.Locked) }); - - expect(emissions).toEqual([ - { id: userId, ...userInfo(AuthenticationStatus.Unlocked) }, - { id: userId, ...userInfo(AuthenticationStatus.Locked) }, + { id: userId, ...userInfo }, ]); }); it("should remember the last emitted value", async () => { - accountsState.stateSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) }); + accountsState.stateSubject.next({ [userId]: userInfo }); activeAccountIdState.stateSubject.next(userId); expect(await firstValueFrom(sut.activeAccount$)).toEqual({ id: userId, - ...userInfo(AuthenticationStatus.Unlocked), + ...userInfo, }); }); }); describe("accounts$", () => { it("should maintain an accounts cache", async () => { - accountsState.stateSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) }); - accountsState.stateSubject.next({ [userId]: userInfo(AuthenticationStatus.Locked) }); + accountsState.stateSubject.next({ [userId]: userInfo }); + accountsState.stateSubject.next({ [userId]: userInfo }); expect(await firstValueFrom(sut.accounts$)).toEqual({ - [userId]: userInfo(AuthenticationStatus.Locked), + [userId]: userInfo, }); }); }); describe("addAccount", () => { it("should emit the new account", async () => { - await sut.addAccount(userId, userInfo(AuthenticationStatus.Unlocked)); + await sut.addAccount(userId, userInfo); const currentValue = await firstValueFrom(sut.accounts$); - expect(currentValue).toEqual({ [userId]: userInfo(AuthenticationStatus.Unlocked) }); + expect(currentValue).toEqual({ [userId]: userInfo }); }); }); describe("setAccountName", () => { - const initialState = { [userId]: userInfo(AuthenticationStatus.Unlocked) }; + const initialState = { [userId]: userInfo }; beforeEach(() => { accountsState.stateSubject.next(initialState); }); @@ -114,7 +99,7 @@ describe("accountService", () => { const currentState = await firstValueFrom(accountsState.state$); expect(currentState).toEqual({ - [userId]: { ...userInfo(AuthenticationStatus.Unlocked), name: "new name" }, + [userId]: { ...userInfo, name: "new name" }, }); }); @@ -127,7 +112,7 @@ describe("accountService", () => { }); describe("setAccountEmail", () => { - const initialState = { [userId]: userInfo(AuthenticationStatus.Unlocked) }; + const initialState = { [userId]: userInfo }; beforeEach(() => { accountsState.stateSubject.next(initialState); }); @@ -137,7 +122,7 @@ describe("accountService", () => { const currentState = await firstValueFrom(accountsState.state$); expect(currentState).toEqual({ - [userId]: { ...userInfo(AuthenticationStatus.Unlocked), email: "new email" }, + [userId]: { ...userInfo, email: "new email" }, }); }); @@ -149,49 +134,9 @@ describe("accountService", () => { }); }); - describe("setAccountStatus", () => { - const initialState = { [userId]: userInfo(AuthenticationStatus.Unlocked) }; - beforeEach(() => { - accountsState.stateSubject.next(initialState); - }); - - it("should update the account", async () => { - await sut.setAccountStatus(userId, AuthenticationStatus.Locked); - const currentState = await firstValueFrom(accountsState.state$); - - expect(currentState).toEqual({ - [userId]: { - ...userInfo(AuthenticationStatus.Unlocked), - status: AuthenticationStatus.Locked, - }, - }); - }); - - it("should not update if the status is the same", async () => { - await sut.setAccountStatus(userId, AuthenticationStatus.Unlocked); - const currentState = await firstValueFrom(accountsState.state$); - - expect(currentState).toEqual(initialState); - }); - - it("should emit logout if the status is logged out", async () => { - const emissions = trackEmissions(sut.accountLogout$); - await sut.setAccountStatus(userId, AuthenticationStatus.LoggedOut); - - expect(emissions).toEqual([userId]); - }); - - it("should emit lock if the status is locked", async () => { - const emissions = trackEmissions(sut.accountLock$); - await sut.setAccountStatus(userId, AuthenticationStatus.Locked); - - expect(emissions).toEqual([userId]); - }); - }); - describe("switchAccount", () => { beforeEach(() => { - accountsState.stateSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) }); + accountsState.stateSubject.next({ [userId]: userInfo }); activeAccountIdState.stateSubject.next(userId); }); @@ -207,26 +152,4 @@ describe("accountService", () => { expect(sut.switchAccount("unknown" as UserId)).rejects.toThrowError("Account does not exist"); }); }); - - describe("setMaxAccountStatus", () => { - it("should update the account", async () => { - accountsState.stateSubject.next({ [userId]: userInfo(AuthenticationStatus.Unlocked) }); - await sut.setMaxAccountStatus(userId, AuthenticationStatus.Locked); - const currentState = await firstValueFrom(accountsState.state$); - - expect(currentState).toEqual({ - [userId]: userInfo(AuthenticationStatus.Locked), - }); - }); - - it("should not update if the new max status is higher than the current", async () => { - accountsState.stateSubject.next({ [userId]: userInfo(AuthenticationStatus.LoggedOut) }); - await sut.setMaxAccountStatus(userId, AuthenticationStatus.Locked); - const currentState = await firstValueFrom(accountsState.state$); - - expect(currentState).toEqual({ - [userId]: userInfo(AuthenticationStatus.LoggedOut), - }); - }); - }); }); diff --git a/libs/common/src/auth/services/account.service.ts b/libs/common/src/auth/services/account.service.ts index 8ef235d815..77d61fae91 100644 --- a/libs/common/src/auth/services/account.service.ts +++ b/libs/common/src/auth/services/account.service.ts @@ -14,7 +14,6 @@ import { KeyDefinition, } from "../../platform/state"; import { UserId } from "../../types/guid"; -import { AuthenticationStatus } from "../enums/authentication-status"; export const ACCOUNT_ACCOUNTS = KeyDefinition.record( ACCOUNT_MEMORY, @@ -36,8 +35,6 @@ export class AccountServiceImplementation implements InternalAccountService { accounts$; activeAccount$; - accountLock$ = this.lock.asObservable(); - accountLogout$ = this.logout.asObservable(); constructor( private messagingService: MessagingService, @@ -74,34 +71,6 @@ export class AccountServiceImplementation implements InternalAccountService { await this.setAccountInfo(userId, { email }); } - async setAccountStatus(userId: UserId, status: AuthenticationStatus): Promise { - await this.setAccountInfo(userId, { status }); - - if (status === AuthenticationStatus.LoggedOut) { - this.logout.next(userId); - } else if (status === AuthenticationStatus.Locked) { - this.lock.next(userId); - } - } - - async setMaxAccountStatus(userId: UserId, maxStatus: AuthenticationStatus): Promise { - await this.accountsState.update( - (accounts) => { - accounts[userId].status = maxStatus; - return accounts; - }, - { - shouldUpdate: (accounts) => { - if (accounts?.[userId] == null) { - throw new Error("Account does not exist"); - } - - return accounts[userId].status > maxStatus; - }, - }, - ); - } - async switchAccount(userId: UserId): Promise { await this.activeAccountIdState.update( (_, accounts) => { diff --git a/libs/common/src/auth/services/auth.service.spec.ts b/libs/common/src/auth/services/auth.service.spec.ts index 07e38def4b..3bdf85d3e1 100644 --- a/libs/common/src/auth/services/auth.service.spec.ts +++ b/libs/common/src/auth/services/auth.service.spec.ts @@ -122,6 +122,25 @@ describe("AuthService", () => { }); }); + describe("authStatuses$", () => { + it("requests auth status for all known users", async () => { + const userId2 = Utils.newGuid() as UserId; + + await accountService.addAccount(userId2, { email: "email2", name: "name2" }); + + const mockFn = jest.fn().mockReturnValue(of(AuthenticationStatus.Locked)); + sut.authStatusFor$ = mockFn; + + await expect(firstValueFrom(await sut.authStatuses$)).resolves.toEqual({ + [userId]: AuthenticationStatus.Locked, + [userId2]: AuthenticationStatus.Locked, + }); + expect(mockFn).toHaveBeenCalledTimes(2); + expect(mockFn).toHaveBeenCalledWith(userId); + expect(mockFn).toHaveBeenCalledWith(userId2); + }); + }); + describe("authStatusFor$", () => { beforeEach(() => { tokenService.hasAccessToken$.mockReturnValue(of(true)); diff --git a/libs/common/src/auth/services/auth.service.ts b/libs/common/src/auth/services/auth.service.ts index de5eb66c06..7a29d313e7 100644 --- a/libs/common/src/auth/services/auth.service.ts +++ b/libs/common/src/auth/services/auth.service.ts @@ -21,6 +21,7 @@ import { AuthenticationStatus } from "../enums/authentication-status"; export class AuthService implements AuthServiceAbstraction { activeAccountStatus$: Observable; + authStatuses$: Observable>; constructor( protected accountService: AccountService, @@ -36,6 +37,26 @@ export class AuthService implements AuthServiceAbstraction { return this.authStatusFor$(userId); }), ); + + this.authStatuses$ = this.accountService.accounts$.pipe( + map((accounts) => Object.keys(accounts) as UserId[]), + switchMap((entries) => + combineLatest( + entries.map((userId) => + this.authStatusFor$(userId).pipe(map((status) => ({ userId, status }))), + ), + ), + ), + map((statuses) => { + return statuses.reduce( + (acc, { userId, status }) => { + acc[userId] = status; + return acc; + }, + {} as Record, + ); + }), + ); } authStatusFor$(userId: UserId): Observable { 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 e65c5cd499..6fb58eab28 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 @@ -14,7 +14,7 @@ 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 { DEVICE_TRUST_DISK_LOCAL, StateProvider, UserKeyDefinition } from "../../platform/state"; import { UserId } from "../../types/guid"; import { UserKey, DeviceKey } from "../../types/key"; import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction"; @@ -27,16 +27,18 @@ import { } 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", { +export const DEVICE_KEY = new UserKeyDefinition(DEVICE_TRUST_DISK_LOCAL, "deviceKey", { deserializer: (deviceKey) => SymmetricCryptoKey.fromJSON(deviceKey) as DeviceKey, + clearOn: [], // Device key is needed to log back into device, so we can't clear it automatically during lock or logout }); /** Uses disk storage so that the shouldTrustDevice bool can persist across login. */ -export const SHOULD_TRUST_DEVICE = new KeyDefinition( +export const SHOULD_TRUST_DEVICE = new UserKeyDefinition( DEVICE_TRUST_DISK_LOCAL, "shouldTrustDevice", { deserializer: (shouldTrustDevice) => shouldTrustDevice, + clearOn: [], // Need to preserve the user setting, so we can't clear it automatically during lock or logout }, ); diff --git a/libs/common/src/auth/services/key-connector.service.spec.ts b/libs/common/src/auth/services/key-connector.service.spec.ts index 50fed856f9..e3e5fbdbe7 100644 --- a/libs/common/src/auth/services/key-connector.service.spec.ts +++ b/libs/common/src/auth/services/key-connector.service.spec.ts @@ -21,6 +21,7 @@ import { CONVERT_ACCOUNT_TO_KEY_CONNECTOR, KeyConnectorService, } from "./key-connector.service"; +import { FakeMasterPasswordService } from "./master-password/fake-master-password.service"; import { TokenService } from "./token.service"; describe("KeyConnectorService", () => { @@ -36,6 +37,7 @@ describe("KeyConnectorService", () => { let stateProvider: FakeStateProvider; let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; const mockUserId = Utils.newGuid() as UserId; const mockOrgId = Utils.newGuid() as OrganizationId; @@ -47,10 +49,13 @@ describe("KeyConnectorService", () => { beforeEach(() => { jest.clearAllMocks(); + masterPasswordService = new FakeMasterPasswordService(); accountService = mockAccountServiceWith(mockUserId); stateProvider = new FakeStateProvider(accountService); keyConnectorService = new KeyConnectorService( + accountService, + masterPasswordService, cryptoService, apiService, tokenService, @@ -214,7 +219,10 @@ describe("KeyConnectorService", () => { // Assert expect(apiService.getMasterKeyFromKeyConnector).toHaveBeenCalledWith(url); - expect(cryptoService.setMasterKey).toHaveBeenCalledWith(masterKey); + expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith( + masterKey, + expect.any(String), + ); }); it("should handle errors thrown during the process", async () => { @@ -241,10 +249,10 @@ describe("KeyConnectorService", () => { // Arrange const organization = organizationData(true, true, "https://key-connector-url.com", 2, false); const masterKey = getMockMasterKey(); + masterPasswordService.masterKeySubject.next(masterKey); const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64); jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization); - jest.spyOn(cryptoService, "getMasterKey").mockResolvedValue(masterKey); jest.spyOn(apiService, "postUserKeyToKeyConnector").mockResolvedValue(); // Act @@ -252,7 +260,6 @@ describe("KeyConnectorService", () => { // Assert expect(keyConnectorService.getManagingOrganization).toHaveBeenCalled(); - expect(cryptoService.getMasterKey).toHaveBeenCalled(); expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith( organization.keyConnectorUrl, keyConnectorRequest, @@ -268,8 +275,8 @@ describe("KeyConnectorService", () => { const error = new Error("Failed to post user key to key connector"); organizationService.getAll.mockResolvedValue([organization]); + masterPasswordService.masterKeySubject.next(masterKey); jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization); - jest.spyOn(cryptoService, "getMasterKey").mockResolvedValue(masterKey); jest.spyOn(apiService, "postUserKeyToKeyConnector").mockRejectedValue(error); jest.spyOn(logService, "error"); @@ -280,7 +287,6 @@ describe("KeyConnectorService", () => { // Assert expect(logService.error).toHaveBeenCalledWith(error); expect(keyConnectorService.getManagingOrganization).toHaveBeenCalled(); - expect(cryptoService.getMasterKey).toHaveBeenCalled(); expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith( organization.keyConnectorUrl, keyConnectorRequest, diff --git a/libs/common/src/auth/services/key-connector.service.ts b/libs/common/src/auth/services/key-connector.service.ts index d1502ce06c..f8e523cce4 100644 --- a/libs/common/src/auth/services/key-connector.service.ts +++ b/libs/common/src/auth/services/key-connector.service.ts @@ -16,7 +16,9 @@ import { UserKeyDefinition, } from "../../platform/state"; import { MasterKey } from "../../types/key"; +import { AccountService } from "../abstractions/account.service"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/key-connector.service"; +import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction"; import { TokenService } from "../abstractions/token.service"; import { KdfConfig } from "../models/domain/kdf-config"; import { KeyConnectorUserKeyRequest } from "../models/request/key-connector-user-key.request"; @@ -45,6 +47,8 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { private usesKeyConnectorState: ActiveUserState; private convertAccountToKeyConnectorState: ActiveUserState; constructor( + private accountService: AccountService, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, private cryptoService: CryptoService, private apiService: ApiService, private tokenService: TokenService, @@ -78,7 +82,8 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { async migrateUser() { const organization = await this.getManagingOrganization(); - const masterKey = await this.cryptoService.getMasterKey(); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64); try { @@ -99,7 +104,8 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { const masterKeyResponse = await this.apiService.getMasterKeyFromKeyConnector(url); const keyArr = Utils.fromB64ToArray(masterKeyResponse.key); const masterKey = new SymmetricCryptoKey(keyArr) as MasterKey; - await this.cryptoService.setMasterKey(masterKey); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setMasterKey(masterKey, userId); } catch (e) { this.handleKeyConnectorError(e); } @@ -136,7 +142,8 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { kdfConfig, ); const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64); - await this.cryptoService.setMasterKey(masterKey); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setMasterKey(masterKey, userId); const userKey = await this.cryptoService.makeUserKey(masterKey); await this.cryptoService.setUserKey(userKey[0]); diff --git a/libs/common/src/auth/services/master-password/fake-master-password.service.ts b/libs/common/src/auth/services/master-password/fake-master-password.service.ts new file mode 100644 index 0000000000..dd034ec50b --- /dev/null +++ b/libs/common/src/auth/services/master-password/fake-master-password.service.ts @@ -0,0 +1,64 @@ +import { mock } from "jest-mock-extended"; +import { ReplaySubject, Observable } from "rxjs"; + +import { EncString } from "../../../platform/models/domain/enc-string"; +import { UserId } from "../../../types/guid"; +import { MasterKey } from "../../../types/key"; +import { InternalMasterPasswordServiceAbstraction } from "../../abstractions/master-password.service.abstraction"; +import { ForceSetPasswordReason } from "../../models/domain/force-set-password-reason"; + +export class FakeMasterPasswordService implements InternalMasterPasswordServiceAbstraction { + mock = mock(); + + // eslint-disable-next-line rxjs/no-exposed-subjects -- test class + masterKeySubject = new ReplaySubject(1); + // eslint-disable-next-line rxjs/no-exposed-subjects -- test class + masterKeyHashSubject = new ReplaySubject(1); + // eslint-disable-next-line rxjs/no-exposed-subjects -- test class + forceSetPasswordReasonSubject = new ReplaySubject(1); + + constructor(initialMasterKey?: MasterKey, initialMasterKeyHash?: string) { + this.masterKeySubject.next(initialMasterKey); + this.masterKeyHashSubject.next(initialMasterKeyHash); + } + + masterKey$(userId: UserId): Observable { + return this.masterKeySubject.asObservable(); + } + + setMasterKey(masterKey: MasterKey, userId: UserId): Promise { + return this.mock.setMasterKey(masterKey, userId); + } + + clearMasterKey(userId: UserId): Promise { + return this.mock.clearMasterKey(userId); + } + + masterKeyHash$(userId: UserId): Observable { + return this.masterKeyHashSubject.asObservable(); + } + + getMasterKeyEncryptedUserKey(userId: UserId): Promise { + return this.mock.getMasterKeyEncryptedUserKey(userId); + } + + setMasterKeyEncryptedUserKey(encryptedKey: EncString, userId: UserId): Promise { + return this.mock.setMasterKeyEncryptedUserKey(encryptedKey, userId); + } + + setMasterKeyHash(masterKeyHash: string, userId: UserId): Promise { + return this.mock.setMasterKeyHash(masterKeyHash, userId); + } + + clearMasterKeyHash(userId: UserId): Promise { + return this.mock.clearMasterKeyHash(userId); + } + + forceSetPasswordReason$(userId: UserId): Observable { + return this.forceSetPasswordReasonSubject.asObservable(); + } + + setForceSetPasswordReason(reason: ForceSetPasswordReason, userId: UserId): Promise { + return this.mock.setForceSetPasswordReason(reason, userId); + } +} diff --git a/libs/common/src/auth/services/master-password/master-password.service.ts b/libs/common/src/auth/services/master-password/master-password.service.ts new file mode 100644 index 0000000000..fad48abc12 --- /dev/null +++ b/libs/common/src/auth/services/master-password/master-password.service.ts @@ -0,0 +1,140 @@ +import { firstValueFrom, map, Observable } from "rxjs"; + +import { EncryptedString, EncString } from "../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { + MASTER_PASSWORD_DISK, + MASTER_PASSWORD_MEMORY, + StateProvider, + UserKeyDefinition, +} from "../../../platform/state"; +import { UserId } from "../../../types/guid"; +import { MasterKey } from "../../../types/key"; +import { InternalMasterPasswordServiceAbstraction } from "../../abstractions/master-password.service.abstraction"; +import { ForceSetPasswordReason } from "../../models/domain/force-set-password-reason"; + +/** Memory since master key shouldn't be available on lock */ +const MASTER_KEY = new UserKeyDefinition(MASTER_PASSWORD_MEMORY, "masterKey", { + deserializer: (masterKey) => SymmetricCryptoKey.fromJSON(masterKey) as MasterKey, + clearOn: ["lock", "logout"], +}); + +/** Disk since master key hash is used for unlock */ +const MASTER_KEY_HASH = new UserKeyDefinition(MASTER_PASSWORD_DISK, "masterKeyHash", { + deserializer: (masterKeyHash) => masterKeyHash, + clearOn: ["logout"], +}); + +/** Disk to persist through lock */ +const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition( + MASTER_PASSWORD_DISK, + "masterKeyEncryptedUserKey", + { + deserializer: (key) => key, + clearOn: ["logout"], + }, +); + +/** Disk to persist through lock and account switches */ +const FORCE_SET_PASSWORD_REASON = new UserKeyDefinition( + MASTER_PASSWORD_DISK, + "forceSetPasswordReason", + { + deserializer: (reason) => reason, + clearOn: ["logout"], + }, +); + +export class MasterPasswordService implements InternalMasterPasswordServiceAbstraction { + constructor(private stateProvider: StateProvider) {} + + masterKey$(userId: UserId): Observable { + if (userId == null) { + throw new Error("User ID is required."); + } + return this.stateProvider.getUser(userId, MASTER_KEY).state$; + } + + masterKeyHash$(userId: UserId): Observable { + if (userId == null) { + throw new Error("User ID is required."); + } + return this.stateProvider.getUser(userId, MASTER_KEY_HASH).state$; + } + + forceSetPasswordReason$(userId: UserId): Observable { + if (userId == null) { + throw new Error("User ID is required."); + } + return this.stateProvider + .getUser(userId, FORCE_SET_PASSWORD_REASON) + .state$.pipe(map((reason) => reason ?? ForceSetPasswordReason.None)); + } + + // TODO: Remove this method and decrypt directly in the service instead + async getMasterKeyEncryptedUserKey(userId: UserId): Promise { + if (userId == null) { + throw new Error("User ID is required."); + } + const key = await firstValueFrom( + this.stateProvider.getUser(userId, MASTER_KEY_ENCRYPTED_USER_KEY).state$, + ); + return EncString.fromJSON(key); + } + + async setMasterKey(masterKey: MasterKey, userId: UserId): Promise { + if (masterKey == null) { + throw new Error("Master key is required."); + } + if (userId == null) { + throw new Error("User ID is required."); + } + await this.stateProvider.getUser(userId, MASTER_KEY).update((_) => masterKey); + } + + async clearMasterKey(userId: UserId): Promise { + if (userId == null) { + throw new Error("User ID is required."); + } + await this.stateProvider.getUser(userId, MASTER_KEY).update((_) => null); + } + + async setMasterKeyHash(masterKeyHash: string, userId: UserId): Promise { + if (masterKeyHash == null) { + throw new Error("Master key hash is required."); + } + if (userId == null) { + throw new Error("User ID is required."); + } + await this.stateProvider.getUser(userId, MASTER_KEY_HASH).update((_) => masterKeyHash); + } + + async clearMasterKeyHash(userId: UserId): Promise { + if (userId == null) { + throw new Error("User ID is required."); + } + await this.stateProvider.getUser(userId, MASTER_KEY_HASH).update((_) => null); + } + + async setMasterKeyEncryptedUserKey(encryptedKey: EncString, userId: UserId): Promise { + if (encryptedKey == null) { + throw new Error("Encrypted Key is required."); + } + if (userId == null) { + throw new Error("User ID is required."); + } + await this.stateProvider + .getUser(userId, MASTER_KEY_ENCRYPTED_USER_KEY) + .update((_) => encryptedKey.toJSON() as EncryptedString); + } + + async setForceSetPasswordReason(reason: ForceSetPasswordReason, userId: UserId): Promise { + if (reason == null) { + throw new Error("Reason is required."); + } + if (userId == null) { + throw new Error("User ID is required."); + } + await this.stateProvider.getUser(userId, FORCE_SET_PASSWORD_REASON).update((_) => reason); + } +} diff --git a/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts b/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts index 408ed33c97..fc5060af5f 100644 --- a/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts +++ b/libs/common/src/auth/services/password-reset-enrollment.service.implementation.spec.ts @@ -8,7 +8,6 @@ import { OrganizationAutoEnrollStatusResponse } from "../../admin-console/models import { CryptoService } from "../../platform/abstractions/crypto.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; import { AccountInfo, AccountService } from "../abstractions/account.service"; -import { AuthenticationStatus } from "../enums/authentication-status"; import { PasswordResetEnrollmentServiceImplementation } from "./password-reset-enrollment.service.implementation"; @@ -91,7 +90,6 @@ describe("PasswordResetEnrollmentServiceImplementation", () => { const user1AccountInfo: AccountInfo = { name: "Test User 1", email: "test1@email.com", - status: AuthenticationStatus.Unlocked, }; activeAccountSubject.next(Object.assign(user1AccountInfo, { id: "userId" as UserId })); diff --git a/libs/common/src/auth/services/sso-login.service.ts b/libs/common/src/auth/services/sso-login.service.ts index 99640e1c6c..3df6ef3540 100644 --- a/libs/common/src/auth/services/sso-login.service.ts +++ b/libs/common/src/auth/services/sso-login.service.ts @@ -6,6 +6,7 @@ import { KeyDefinition, SSO_DISK, StateProvider, + UserKeyDefinition, } from "../../platform/state"; import { SsoLoginServiceAbstraction } from "../abstractions/sso-login.service.abstraction"; @@ -26,7 +27,19 @@ const SSO_STATE = new KeyDefinition(SSO_DISK, "ssoState", { /** * Uses disk storage so that the organization sso identifier can be persisted across sso redirects. */ -const ORGANIZATION_SSO_IDENTIFIER = new KeyDefinition( +const USER_ORGANIZATION_SSO_IDENTIFIER = new UserKeyDefinition( + SSO_DISK, + "organizationSsoIdentifier", + { + deserializer: (organizationIdentifier) => organizationIdentifier, + clearOn: ["logout"], // Used for login, so not needed past logout + }, +); + +/** + * Uses disk storage so that the organization sso identifier can be persisted across sso redirects. + */ +const GLOBAL_ORGANIZATION_SSO_IDENTIFIER = new KeyDefinition( SSO_DISK, "organizationSsoIdentifier", { @@ -51,10 +64,10 @@ export class SsoLoginService implements SsoLoginServiceAbstraction { constructor(private stateProvider: StateProvider) { this.codeVerifierState = this.stateProvider.getGlobal(CODE_VERIFIER); this.ssoState = this.stateProvider.getGlobal(SSO_STATE); - this.orgSsoIdentifierState = this.stateProvider.getGlobal(ORGANIZATION_SSO_IDENTIFIER); + this.orgSsoIdentifierState = this.stateProvider.getGlobal(GLOBAL_ORGANIZATION_SSO_IDENTIFIER); this.ssoEmailState = this.stateProvider.getGlobal(SSO_EMAIL); this.activeUserOrgSsoIdentifierState = this.stateProvider.getActive( - ORGANIZATION_SSO_IDENTIFIER, + USER_ORGANIZATION_SSO_IDENTIFIER, ); } diff --git a/libs/common/src/auth/services/token.service.ts b/libs/common/src/auth/services/token.service.ts index fb13c21870..db39997663 100644 --- a/libs/common/src/auth/services/token.service.ts +++ b/libs/common/src/auth/services/token.service.ts @@ -15,8 +15,8 @@ import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypt import { GlobalState, GlobalStateProvider, - KeyDefinition, SingleUserStateProvider, + UserKeyDefinition, } from "../../platform/state"; import { UserId } from "../../types/guid"; import { TokenService as TokenServiceAbstraction } from "../abstractions/token.service"; @@ -863,7 +863,7 @@ export class TokenService implements TokenServiceAbstraction { private async getStateValueByUserIdAndKeyDef( userId: UserId, - storageLocation: KeyDefinition, + storageLocation: UserKeyDefinition, ): Promise { // read from single user state provider return await firstValueFrom(this.singleUserStateProvider.get(userId, storageLocation).state$); diff --git a/libs/common/src/auth/services/token.state.spec.ts b/libs/common/src/auth/services/token.state.spec.ts index 24eddc73f5..55f97b7e00 100644 --- a/libs/common/src/auth/services/token.state.spec.ts +++ b/libs/common/src/auth/services/token.state.spec.ts @@ -1,4 +1,4 @@ -import { KeyDefinition } from "../../platform/state"; +import { KeyDefinition, UserKeyDefinition } from "../../platform/state"; import { ACCESS_TOKEN_DISK, @@ -28,8 +28,8 @@ describe.each([ "deserializes state key definitions", ( keyDefinition: - | KeyDefinition - | KeyDefinition + | UserKeyDefinition + | UserKeyDefinition | KeyDefinition>, state: string | boolean | Record, ) => { @@ -50,7 +50,10 @@ describe.each([ return typeof value === "object" && value !== null && !Array.isArray(value); } - function testDeserialization(keyDefinition: KeyDefinition, state: T) { + function testDeserialization( + keyDefinition: KeyDefinition | UserKeyDefinition, + state: T, + ) { const deserialized = keyDefinition.deserializer(JSON.parse(JSON.stringify(state))); expect(deserialized).toEqual(state); } diff --git a/libs/common/src/auth/services/token.state.ts b/libs/common/src/auth/services/token.state.ts index 368f3c4ca2..a8c6878fbb 100644 --- a/libs/common/src/auth/services/token.state.ts +++ b/libs/common/src/auth/services/token.state.ts @@ -1,30 +1,41 @@ -import { KeyDefinition, TOKEN_DISK, TOKEN_DISK_LOCAL, TOKEN_MEMORY } from "../../platform/state"; +import { + KeyDefinition, + TOKEN_DISK, + TOKEN_DISK_LOCAL, + TOKEN_MEMORY, + UserKeyDefinition, +} from "../../platform/state"; // Note: all tokens / API key information must be cleared on logout. // because we are using secure storage, we must manually call to clean up our tokens. // See stateService.deAuthenticateAccount for where we call clearTokens(...) -export const ACCESS_TOKEN_DISK = new KeyDefinition(TOKEN_DISK, "accessToken", { +export const ACCESS_TOKEN_DISK = new UserKeyDefinition(TOKEN_DISK, "accessToken", { deserializer: (accessToken) => accessToken, + clearOn: [], // Manually handled }); -export const ACCESS_TOKEN_MEMORY = new KeyDefinition(TOKEN_MEMORY, "accessToken", { +export const ACCESS_TOKEN_MEMORY = new UserKeyDefinition(TOKEN_MEMORY, "accessToken", { deserializer: (accessToken) => accessToken, + clearOn: [], // Manually handled }); -export const REFRESH_TOKEN_DISK = new KeyDefinition(TOKEN_DISK, "refreshToken", { +export const REFRESH_TOKEN_DISK = new UserKeyDefinition(TOKEN_DISK, "refreshToken", { deserializer: (refreshToken) => refreshToken, + clearOn: [], // Manually handled }); -export const REFRESH_TOKEN_MEMORY = new KeyDefinition(TOKEN_MEMORY, "refreshToken", { +export const REFRESH_TOKEN_MEMORY = new UserKeyDefinition(TOKEN_MEMORY, "refreshToken", { deserializer: (refreshToken) => refreshToken, + clearOn: [], // Manually handled }); -export const REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE = new KeyDefinition( +export const REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE = new UserKeyDefinition( TOKEN_DISK, "refreshTokenMigratedToSecureStorage", { deserializer: (refreshTokenMigratedToSecureStorage) => refreshTokenMigratedToSecureStorage, + clearOn: [], // Don't clear on lock/logout so that we always check the correct place (secure storage) for the refresh token if it's been migrated }, ); @@ -36,26 +47,34 @@ export const EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL = KeyDefinition.record(TOKEN_DISK, "apiKeyClientId", { +export const API_KEY_CLIENT_ID_DISK = new UserKeyDefinition(TOKEN_DISK, "apiKeyClientId", { deserializer: (apiKeyClientId) => apiKeyClientId, + clearOn: [], // Manually handled }); -export const API_KEY_CLIENT_ID_MEMORY = new KeyDefinition(TOKEN_MEMORY, "apiKeyClientId", { - deserializer: (apiKeyClientId) => apiKeyClientId, -}); +export const API_KEY_CLIENT_ID_MEMORY = new UserKeyDefinition( + TOKEN_MEMORY, + "apiKeyClientId", + { + deserializer: (apiKeyClientId) => apiKeyClientId, + clearOn: [], // Manually handled + }, +); -export const API_KEY_CLIENT_SECRET_DISK = new KeyDefinition( +export const API_KEY_CLIENT_SECRET_DISK = new UserKeyDefinition( TOKEN_DISK, "apiKeyClientSecret", { deserializer: (apiKeyClientSecret) => apiKeyClientSecret, + clearOn: [], // Manually handled }, ); -export const API_KEY_CLIENT_SECRET_MEMORY = new KeyDefinition( +export const API_KEY_CLIENT_SECRET_MEMORY = new UserKeyDefinition( TOKEN_MEMORY, "apiKeyClientSecret", { deserializer: (apiKeyClientSecret) => apiKeyClientSecret, + clearOn: [], // Manually handled }, ); diff --git a/libs/common/src/auth/services/user-verification/user-verification.service.ts b/libs/common/src/auth/services/user-verification/user-verification.service.ts index 0b4cd96099..5a443b784d 100644 --- a/libs/common/src/auth/services/user-verification/user-verification.service.ts +++ b/libs/common/src/auth/services/user-verification/user-verification.service.ts @@ -10,7 +10,10 @@ import { LogService } from "../../../platform/abstractions/log.service"; import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service"; import { StateService } from "../../../platform/abstractions/state.service"; import { KeySuffixOptions } from "../../../platform/enums/key-suffix-options.enum"; +import { UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; +import { AccountService } from "../../abstractions/account.service"; +import { InternalMasterPasswordServiceAbstraction } from "../../abstractions/master-password.service.abstraction"; import { UserVerificationApiServiceAbstraction } from "../../abstractions/user-verification/user-verification-api.service.abstraction"; import { UserVerificationService as UserVerificationServiceAbstraction } from "../../abstractions/user-verification/user-verification.service.abstraction"; import { VerificationType } from "../../enums/verification-type"; @@ -35,6 +38,8 @@ export class UserVerificationService implements UserVerificationServiceAbstracti constructor( private stateService: StateService, private cryptoService: CryptoService, + private accountService: AccountService, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, private i18nService: I18nService, private userVerificationApiService: UserVerificationApiServiceAbstraction, private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, @@ -107,7 +112,8 @@ export class UserVerificationService implements UserVerificationServiceAbstracti if (verification.type === VerificationType.OTP) { request.otp = verification.secret; } else { - let masterKey = await this.cryptoService.getMasterKey(); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + let masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); if (!masterKey && !alreadyHashed) { masterKey = await this.cryptoService.makeMasterKey( verification.secret, @@ -164,7 +170,8 @@ export class UserVerificationService implements UserVerificationServiceAbstracti private async verifyUserByMasterPassword( verification: MasterPasswordVerification, ): Promise { - let masterKey = await this.cryptoService.getMasterKey(); + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + let masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); if (!masterKey) { masterKey = await this.cryptoService.makeMasterKey( verification.secret, @@ -181,7 +188,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti throw new Error(this.i18nService.t("invalidMasterPassword")); } // TODO: we should re-evaluate later on if user verification should have the side effect of modifying state. Probably not. - await this.cryptoService.setMasterKey(masterKey); + await this.masterPasswordService.setMasterKey(masterKey, userId); return true; } @@ -230,9 +237,10 @@ export class UserVerificationService implements UserVerificationServiceAbstracti } async hasMasterPasswordAndMasterKeyHash(userId?: string): Promise { + userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id; return ( (await this.hasMasterPassword(userId)) && - (await this.cryptoService.getMasterKeyHash()) != null + (await firstValueFrom(this.masterPasswordService.masterKeyHash$(userId as UserId))) != null ); } diff --git a/libs/common/src/billing/enums/plan-type.enum.ts b/libs/common/src/billing/enums/plan-type.enum.ts index 38febc50e4..c897770345 100644 --- a/libs/common/src/billing/enums/plan-type.enum.ts +++ b/libs/common/src/billing/enums/plan-type.enum.ts @@ -11,9 +11,14 @@ export enum PlanType { TeamsAnnually2020 = 9, EnterpriseMonthly2020 = 10, EnterpriseAnnually2020 = 11, - TeamsMonthly = 12, - TeamsAnnually = 13, - EnterpriseMonthly = 14, - EnterpriseAnnually = 15, - TeamsStarter = 16, + TeamsMonthly2023 = 12, + TeamsAnnually2023 = 13, + EnterpriseMonthly2023 = 14, + EnterpriseAnnually2023 = 15, + TeamsStarter2023 = 16, + TeamsMonthly = 17, + TeamsAnnually = 18, + EnterpriseMonthly = 19, + EnterpriseAnnually = 20, + TeamsStarter = 21, } diff --git a/libs/common/src/billing/models/request/organization-tax-info-update.request.ts b/libs/common/src/billing/models/request/expanded-tax-info-update.request.ts similarity index 66% rename from libs/common/src/billing/models/request/organization-tax-info-update.request.ts rename to libs/common/src/billing/models/request/expanded-tax-info-update.request.ts index 0f8ec92160..6589b9c1df 100644 --- a/libs/common/src/billing/models/request/organization-tax-info-update.request.ts +++ b/libs/common/src/billing/models/request/expanded-tax-info-update.request.ts @@ -1,6 +1,6 @@ import { TaxInfoUpdateRequest } from "./tax-info-update.request"; -export class OrganizationTaxInfoUpdateRequest extends TaxInfoUpdateRequest { +export class ExpandedTaxInfoUpdateRequest extends TaxInfoUpdateRequest { taxId: string; line1: string; line2: string; diff --git a/libs/common/src/billing/models/request/payment.request.ts b/libs/common/src/billing/models/request/payment.request.ts index d54ca91f62..e73a10bcea 100644 --- a/libs/common/src/billing/models/request/payment.request.ts +++ b/libs/common/src/billing/models/request/payment.request.ts @@ -1,8 +1,8 @@ import { PaymentMethodType } from "../../enums"; -import { OrganizationTaxInfoUpdateRequest } from "./organization-tax-info-update.request"; +import { ExpandedTaxInfoUpdateRequest } from "./expanded-tax-info-update.request"; -export class PaymentRequest extends OrganizationTaxInfoUpdateRequest { +export class PaymentRequest extends ExpandedTaxInfoUpdateRequest { paymentMethodType: PaymentMethodType; paymentToken: string; } diff --git a/libs/common/src/billing/services/organization-billing.service.ts b/libs/common/src/billing/services/organization-billing.service.ts index a9437e288c..f2df30e4e0 100644 --- a/libs/common/src/billing/services/organization-billing.service.ts +++ b/libs/common/src/billing/services/organization-billing.service.ts @@ -81,6 +81,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs case PlanType.Free: case PlanType.FamiliesAnnually: case PlanType.FamiliesAnnually2019: + case PlanType.TeamsStarter2023: case PlanType.TeamsStarter: return true; default: diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 9d427034bd..b937e6c462 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -9,6 +9,7 @@ export enum FeatureFlag { ShowPaymentMethodWarningBanners = "show-payment-method-warning-banners", EnableConsolidatedBilling = "enable-consolidated-billing", AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section", + UnassignedItemsBanner = "unassigned-items-banner", } // Replace this with a type safe lookup of the feature flag values in PM-2282 diff --git a/libs/common/src/platform/abstractions/crypto.service.ts b/libs/common/src/platform/abstractions/crypto.service.ts index 85b2bfe82e..6609a1014e 100644 --- a/libs/common/src/platform/abstractions/crypto.service.ts +++ b/libs/common/src/platform/abstractions/crypto.service.ts @@ -26,7 +26,7 @@ export abstract class CryptoService { * any other necessary versions (such as auto, biometrics, * or pin) * - * @throws when key is null. Use {@link clearUserKey} instead + * @throws when key is null. Lock the account to clear a key * @param key The user key to set * @param userId The desired user */ @@ -93,13 +93,6 @@ export abstract class CryptoService { * @returns A new user key and the master key protected version of it */ abstract makeUserKey(key: MasterKey): Promise<[UserKey, EncString]>; - /** - * Clears the user key - * @param clearStoredKeys Clears all stored versions of the user keys as well, - * such as the biometrics key - * @param userId The desired user - */ - abstract clearUserKey(clearSecretStorage?: boolean, userId?: string): Promise; /** * Clears the user's stored version of the user key * @param keySuffix The desired version of the key to clear @@ -112,18 +105,6 @@ export abstract class CryptoService { * @param userId The desired user */ abstract setMasterKeyEncryptedUserKey(UserKeyMasterKey: string, userId?: string): Promise; - /** - * Sets the user's master key - * @param key The user's master key to set - * @param userId The desired user - */ - abstract setMasterKey(key: MasterKey, userId?: string): Promise; - /** - * @param userId The desired user - * @returns The user's master key - */ - abstract getMasterKey(userId?: string): Promise; - /** * @param password The user's master password that will be used to derive a master key if one isn't found * @param userId The desired user @@ -143,11 +124,6 @@ export abstract class CryptoService { kdf: KdfType, KdfConfig: KdfConfig, ): Promise; - /** - * Clears the user's master key - * @param userId The desired user - */ - abstract clearMasterKey(userId?: string): Promise; /** * Encrypts the existing (or provided) user key with the * provided master key @@ -185,20 +161,6 @@ export abstract class CryptoService { key: MasterKey, hashPurpose?: HashPurpose, ): Promise; - /** - * Sets the user's master password hash - * @param keyHash The user's master password hash to set - */ - abstract setMasterKeyHash(keyHash: string): Promise; - /** - * @returns The user's master password hash - */ - abstract getMasterKeyHash(): Promise; - /** - * Clears the user's stored master password hash - * @param userId The desired user - */ - abstract clearMasterKeyHash(userId?: string): Promise; /** * Compares the provided master password to the stored password hash and server password hash. * Updates the stored hash if outdated. @@ -238,12 +200,6 @@ export abstract class CryptoService { abstract makeDataEncKey( key: T, ): Promise<[SymmetricCryptoKey, EncString]>; - /** - * Clears the user's stored organization keys - * @param memoryOnly Clear only the in-memory keys - * @param userId The desired user - */ - abstract clearOrgKeys(memoryOnly?: boolean, userId?: string): Promise; /** * Stores the encrypted provider keys and clears any decrypted * provider keys currently in memory @@ -260,11 +216,6 @@ export abstract class CryptoService { * @returns A record of the provider Ids to their symmetric keys */ abstract getProviderKeys(): Promise>; - /** - * @param memoryOnly Clear only the in-memory keys - * @param userId The desired user - */ - abstract clearProviderKeys(memoryOnly?: boolean, userId?: string): Promise; /** * Returns the public key from memory. If not available, extracts it * from the private key and stores it in memory @@ -304,12 +255,6 @@ export abstract class CryptoService { * @returns A new keypair: [publicKey in Base64, encrypted privateKey] */ abstract makeKeyPair(key?: SymmetricCryptoKey): Promise<[string, EncString]>; - /** - * Clears the user's key pair - * @param memoryOnly Clear only the in-memory keys - * @param userId The desired user - */ - abstract clearKeyPair(memoryOnly?: boolean, userId?: string): Promise; /** * @param pin The user's pin * @param salt The user's salt diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index 4971481381..2348c8844a 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -1,23 +1,15 @@ import { Observable } from "rxjs"; -import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable"; -import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason"; import { KdfConfig } from "../../auth/models/domain/kdf-config"; import { BiometricKey } from "../../auth/types/biometric-key"; import { GeneratorOptions } from "../../tools/generator/generator-options"; import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password"; import { UsernameGeneratorOptions } from "../../tools/generator/username"; import { UserId } from "../../types/guid"; -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"; -import { AddEditCipherInfo } from "../../vault/types/add-edit-cipher-info"; import { KdfType } from "../enums"; import { Account } from "../models/domain/account"; import { EncString } from "../models/domain/enc-string"; import { StorageOptions } from "../models/domain/storage-options"; -import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; /** * Options for customizing the initiation behavior. @@ -36,34 +28,12 @@ export type InitOptions = { export abstract class StateService { accounts$: Observable<{ [userId: string]: T }>; activeAccount$: Observable; - /** - * @deprecated use accountService.activeAccount$ instead - */ - activeAccountUnlocked$: Observable; addAccount: (account: T) => Promise; setActiveUser: (userId: string) => Promise; clean: (options?: StorageOptions) => Promise; init: (initOptions?: InitOptions) => Promise; - getAddEditCipherInfo: (options?: StorageOptions) => Promise; - setAddEditCipherInfo: (value: AddEditCipherInfo, options?: StorageOptions) => Promise; - /** - * Gets the user's master key - */ - getMasterKey: (options?: StorageOptions) => Promise; - /** - * Sets the user's master key - */ - setMasterKey: (value: MasterKey, options?: StorageOptions) => Promise; - /** - * Gets the user key encrypted by the master key - */ - getMasterKeyEncryptedUserKey: (options?: StorageOptions) => Promise; - /** - * Sets the user key encrypted by the master key - */ - setMasterKeyEncryptedUserKey: (value: string, options?: StorageOptions) => Promise; /** * Gets the user's auto key */ @@ -108,10 +78,6 @@ export abstract class StateService { * @deprecated For migration purposes only, use getUserKeyMasterKey instead */ getEncryptedCryptoSymmetricKey: (options?: StorageOptions) => Promise; - /** - * @deprecated For legacy purposes only, use getMasterKey instead - */ - getCryptoMasterKey: (options?: StorageOptions) => Promise; /** * @deprecated For migration purposes only, use getUserKeyAuto instead */ @@ -132,8 +98,6 @@ export abstract class StateService { * @deprecated For migration purposes only, use setUserKeyBiometric instead */ setCryptoMasterKeyBiometric: (value: BiometricKey, options?: StorageOptions) => Promise; - getDecryptedCiphers: (options?: StorageOptions) => Promise; - setDecryptedCiphers: (value: CipherView[], options?: StorageOptions) => Promise; getDecryptedPasswordGenerationHistory: ( options?: StorageOptions, ) => Promise; @@ -151,11 +115,6 @@ export abstract class StateService { setDecryptedPinProtected: (value: EncString, options?: StorageOptions) => Promise; getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise; setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise; - getAdminAuthRequest: (options?: StorageOptions) => Promise; - setAdminAuthRequest: ( - adminAuthRequest: AdminAuthRequestStorable, - options?: StorageOptions, - ) => Promise; getEmail: (options?: StorageOptions) => Promise; setEmail: (value: string, options?: StorageOptions) => Promise; getEmailVerified: (options?: StorageOptions) => Promise; @@ -167,11 +126,6 @@ export abstract class StateService { value: boolean, options?: StorageOptions, ) => Promise; - getEncryptedCiphers: (options?: StorageOptions) => Promise<{ [id: string]: CipherData }>; - setEncryptedCiphers: ( - value: { [id: string]: CipherData }, - options?: StorageOptions, - ) => Promise; getEncryptedPasswordGenerationHistory: ( options?: StorageOptions, ) => Promise; @@ -189,27 +143,15 @@ export abstract class StateService { setEncryptedPinProtected: (value: string, options?: StorageOptions) => Promise; getEverBeenUnlocked: (options?: StorageOptions) => Promise; setEverBeenUnlocked: (value: boolean, options?: StorageOptions) => Promise; - getForceSetPasswordReason: (options?: StorageOptions) => Promise; - setForceSetPasswordReason: ( - value: ForceSetPasswordReason, - options?: StorageOptions, - ) => Promise; getIsAuthenticated: (options?: StorageOptions) => Promise; getKdfConfig: (options?: StorageOptions) => Promise; setKdfConfig: (kdfConfig: KdfConfig, options?: StorageOptions) => Promise; getKdfType: (options?: StorageOptions) => Promise; setKdfType: (value: KdfType, options?: StorageOptions) => Promise; - getKeyHash: (options?: StorageOptions) => Promise; - setKeyHash: (value: string, options?: StorageOptions) => Promise; getLastActive: (options?: StorageOptions) => Promise; setLastActive: (value: number, options?: StorageOptions) => Promise; getLastSync: (options?: StorageOptions) => Promise; setLastSync: (value: string, options?: StorageOptions) => Promise; - getLocalData: (options?: StorageOptions) => Promise<{ [cipherId: string]: LocalData }>; - setLocalData: ( - value: { [cipherId: string]: LocalData }, - options?: StorageOptions, - ) => Promise; getMinimizeOnCopyToClipboard: (options?: StorageOptions) => Promise; setMinimizeOnCopyToClipboard: (value: boolean, options?: StorageOptions) => Promise; getOrganizationInvitation: (options?: StorageOptions) => Promise; @@ -241,7 +183,5 @@ export abstract class StateService { setVaultTimeout: (value: number, options?: StorageOptions) => Promise; getVaultTimeoutAction: (options?: StorageOptions) => Promise; setVaultTimeoutAction: (value: string, options?: StorageOptions) => Promise; - getApproveLoginRequests: (options?: StorageOptions) => Promise; - setApproveLoginRequests: (value: boolean, options?: StorageOptions) => Promise; nextUpActiveUser: () => Promise; } diff --git a/libs/common/src/platform/biometrics/biometric.state.spec.ts b/libs/common/src/platform/biometrics/biometric.state.spec.ts index 420a0fb86e..7bcccd2ea9 100644 --- a/libs/common/src/platform/biometrics/biometric.state.spec.ts +++ b/libs/common/src/platform/biometrics/biometric.state.spec.ts @@ -1,5 +1,5 @@ import { EncryptedString } from "../models/domain/enc-string"; -import { KeyDefinition } from "../state"; +import { KeyDefinition, UserKeyDefinition } from "../state"; import { BIOMETRIC_UNLOCK_ENABLED, @@ -22,9 +22,15 @@ describe.each([ ])( "deserializes state %s", ( - ...args: [KeyDefinition, EncryptedString] | [KeyDefinition, boolean] + ...args: + | [UserKeyDefinition, EncryptedString] + | [UserKeyDefinition, boolean] + | [KeyDefinition, boolean] ) => { - function testDeserialization(keyDefinition: KeyDefinition, state: T) { + function testDeserialization( + keyDefinition: UserKeyDefinition | KeyDefinition, + state: T, + ) { const deserialized = keyDefinition.deserializer(JSON.parse(JSON.stringify(state))); expect(deserialized).toEqual(state); } diff --git a/libs/common/src/platform/biometrics/biometric.state.ts b/libs/common/src/platform/biometrics/biometric.state.ts index aa16e14baa..bcefb7b215 100644 --- a/libs/common/src/platform/biometrics/biometric.state.ts +++ b/libs/common/src/platform/biometrics/biometric.state.ts @@ -1,15 +1,16 @@ import { UserId } from "../../types/guid"; import { EncryptedString } from "../models/domain/enc-string"; -import { KeyDefinition, BIOMETRIC_SETTINGS_DISK } from "../state"; +import { KeyDefinition, BIOMETRIC_SETTINGS_DISK, UserKeyDefinition } from "../state"; /** * Indicates whether the user elected to store a biometric key to unlock their vault. */ -export const BIOMETRIC_UNLOCK_ENABLED = new KeyDefinition( +export const BIOMETRIC_UNLOCK_ENABLED = new UserKeyDefinition( BIOMETRIC_SETTINGS_DISK, "biometricUnlockEnabled", { deserializer: (obj) => obj, + clearOn: [], }, ); @@ -18,11 +19,12 @@ export const BIOMETRIC_UNLOCK_ENABLED = new KeyDefinition( * * A true setting controls whether {@link ENCRYPTED_CLIENT_KEY_HALF} is set. */ -export const REQUIRE_PASSWORD_ON_START = new KeyDefinition( +export const REQUIRE_PASSWORD_ON_START = new UserKeyDefinition( BIOMETRIC_SETTINGS_DISK, "requirePasswordOnStart", { deserializer: (value) => value, + clearOn: [], }, ); @@ -33,11 +35,12 @@ export const REQUIRE_PASSWORD_ON_START = new KeyDefinition( * For operating systems without application-level key storage, this key half is concatenated with a signature * provided by the OS and used to encrypt the biometric key prior to storage. */ -export const ENCRYPTED_CLIENT_KEY_HALF = new KeyDefinition( +export const ENCRYPTED_CLIENT_KEY_HALF = new UserKeyDefinition( BIOMETRIC_SETTINGS_DISK, "clientKeyHalf", { deserializer: (obj) => obj, + clearOn: ["logout"], }, ); @@ -45,11 +48,12 @@ export const ENCRYPTED_CLIENT_KEY_HALF = new KeyDefinition( * Indicates the user has been warned about the security implications of using biometrics and, depending on the OS, * recommended to require a password on first unlock of an application instance. */ -export const DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT = new KeyDefinition( +export const DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT = new UserKeyDefinition( BIOMETRIC_SETTINGS_DISK, "dismissedBiometricRequirePasswordOnStartCallout", { deserializer: (obj) => obj, + clearOn: [], }, ); @@ -68,11 +72,12 @@ export const PROMPT_CANCELLED = KeyDefinition.record( /** * Stores whether the user has elected to automatically prompt for biometric unlock on application start. */ -export const PROMPT_AUTOMATICALLY = new KeyDefinition( +export const PROMPT_AUTOMATICALLY = new UserKeyDefinition( BIOMETRIC_SETTINGS_DISK, "promptAutomatically", { deserializer: (obj) => obj, + clearOn: [], }, ); diff --git a/libs/common/src/platform/misc/flags.ts b/libs/common/src/platform/misc/flags.ts index c1f8d7757b..cc463b1060 100644 --- a/libs/common/src/platform/misc/flags.ts +++ b/libs/common/src/platform/misc/flags.ts @@ -1,5 +1,5 @@ // required to avoid linting errors when there are no flags -/* eslint-disable @typescript-eslint/ban-types */ +// eslint-disable-next-line @typescript-eslint/ban-types export type SharedFlags = { multithreadDecryption: boolean; showPasswordless?: boolean; @@ -7,7 +7,7 @@ export type SharedFlags = { }; // required to avoid linting errors when there are no flags -/* eslint-disable @typescript-eslint/ban-types */ +// eslint-disable-next-line @typescript-eslint/ban-types export type SharedDevFlags = { noopNotifications: boolean; }; 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 4a96da1b48..6bdb08edd5 100644 --- a/libs/common/src/platform/models/domain/account-keys.spec.ts +++ b/libs/common/src/platform/models/domain/account-keys.spec.ts @@ -2,7 +2,6 @@ import { makeStaticByteArray } from "../../../../spec"; import { Utils } from "../../misc/utils"; import { AccountKeys, EncryptionPair } from "./account"; -import { SymmetricCryptoKey } from "./symmetric-crypto-key"; describe("AccountKeys", () => { describe("toJSON", () => { @@ -32,12 +31,6 @@ describe("AccountKeys", () => { expect(keys.publicKey).toEqual(Utils.fromByteStringToArray("hello")); }); - it("should deserialize cryptoMasterKey", () => { - const spy = jest.spyOn(SymmetricCryptoKey, "fromJSON"); - AccountKeys.fromJSON({} as any); - expect(spy).toHaveBeenCalled(); - }); - it("should deserialize privateKey", () => { const spy = jest.spyOn(EncryptionPair, "fromJSON"); AccountKeys.fromJSON({ diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index 4ed36fd389..ae7780ada4 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -1,7 +1,5 @@ import { Jsonify } from "type-fest"; -import { AdminAuthRequestStorable } from "../../../auth/models/domain/admin-auth-req-storable"; -import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason"; import { UriMatchStrategySetting } from "../../../models/domain/domain-service"; import { GeneratorOptions } from "../../../tools/generator/generator-options"; import { @@ -10,10 +8,6 @@ import { } from "../../../tools/generator/password"; import { UsernameGeneratorOptions } from "../../../tools/generator/username/username-generation-options"; import { DeepJsonify } from "../../../types/deep-jsonify"; -import { MasterKey } from "../../../types/key"; -import { CipherData } from "../../../vault/models/data/cipher.data"; -import { CipherView } from "../../../vault/models/view/cipher.view"; -import { AddEditCipherInfo } from "../../../vault/types/add-edit-cipher-info"; import { KdfType } from "../../enums"; import { Utils } from "../../misc/utils"; @@ -64,38 +58,23 @@ export class DataEncryptionPair { } export class AccountData { - ciphers?: DataEncryptionPair = new DataEncryptionPair< - CipherData, - CipherView - >(); - localData?: any; passwordGenerationHistory?: EncryptionPair< GeneratedPasswordHistory[], GeneratedPasswordHistory[] > = new EncryptionPair(); - addEditCipherInfo?: AddEditCipherInfo; static fromJSON(obj: DeepJsonify): AccountData { if (obj == null) { return null; } - return Object.assign(new AccountData(), obj, { - addEditCipherInfo: { - cipher: CipherView.fromJSON(obj?.addEditCipherInfo?.cipher), - collectionIds: obj?.addEditCipherInfo?.collectionIds, - }, - }); + return Object.assign(new AccountData(), obj); } } export class AccountKeys { - masterKey?: MasterKey; - masterKeyEncryptedUserKey?: string; publicKey?: Uint8Array; - /** @deprecated July 2023, left for migration purposes*/ - cryptoMasterKey?: SymmetricCryptoKey; /** @deprecated July 2023, left for migration purposes*/ cryptoMasterKeyAuto?: string; /** @deprecated July 2023, left for migration purposes*/ @@ -120,8 +99,6 @@ export class AccountKeys { return null; } return Object.assign(new AccountKeys(), obj, { - masterKey: SymmetricCryptoKey.fromJSON(obj?.masterKey), - cryptoMasterKey: SymmetricCryptoKey.fromJSON(obj?.cryptoMasterKey), cryptoSymmetricKey: EncryptionPair.fromJSON( obj?.cryptoSymmetricKey, SymmetricCryptoKey.fromJSON, @@ -150,10 +127,8 @@ export class AccountProfile { email?: string; emailVerified?: boolean; everBeenUnlocked?: boolean; - forceSetPasswordReason?: ForceSetPasswordReason; lastSync?: string; userId?: string; - keyHash?: string; kdfIterations?: number; kdfMemory?: number; kdfParallelism?: number; @@ -179,7 +154,6 @@ export class AccountSettings { protectedPin?: string; vaultTimeout?: number; vaultTimeoutAction?: string = "lock"; - approveLoginRequests?: boolean; /** @deprecated July 2023, left for migration purposes*/ pinProtected?: EncryptionPair = new EncryptionPair(); @@ -216,7 +190,6 @@ export class Account { profile?: AccountProfile = new AccountProfile(); settings?: AccountSettings = new AccountSettings(); tokens?: AccountTokens = new AccountTokens(); - adminAuthRequest?: Jsonify = null; constructor(init: Partial) { Object.assign(this, { @@ -240,7 +213,6 @@ export class Account { ...new AccountTokens(), ...init?.tokens, }, - adminAuthRequest: init?.adminAuthRequest, }); } @@ -255,7 +227,6 @@ export class Account { profile: AccountProfile.fromJSON(json?.profile), settings: AccountSettings.fromJSON(json?.settings), tokens: AccountTokens.fromJSON(json?.tokens), - adminAuthRequest: AdminAuthRequestStorable.fromJSON(json?.adminAuthRequest), }); } } diff --git a/libs/common/src/platform/services/config/default-config.service.ts b/libs/common/src/platform/services/config/default-config.service.ts index 9532b903d3..e124deccf8 100644 --- a/libs/common/src/platform/services/config/default-config.service.ts +++ b/libs/common/src/platform/services/config/default-config.service.ts @@ -32,7 +32,6 @@ export const USER_SERVER_CONFIG = new UserKeyDefinition(CONFIG_DIS clearOn: ["logout"], }); -// TODO MDG: When to clean these up? export const GLOBAL_SERVER_CONFIGURATIONS = KeyDefinition.record( CONFIG_DISK, "byServer", diff --git a/libs/common/src/platform/services/crypto.service.spec.ts b/libs/common/src/platform/services/crypto.service.spec.ts index 9160664aa5..16e6d4aa63 100644 --- a/libs/common/src/platform/services/crypto.service.spec.ts +++ b/libs/common/src/platform/services/crypto.service.spec.ts @@ -1,10 +1,10 @@ import { mock } from "jest-mock-extended"; -import { firstValueFrom, of } from "rxjs"; +import { firstValueFrom, of, tap } from "rxjs"; import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service"; import { FakeActiveUserState, FakeSingleUserState } from "../../../spec/fake-state"; import { FakeStateProvider } from "../../../spec/fake-state-provider"; -import { AuthenticationStatus } from "../../auth/enums/authentication-status"; +import { FakeMasterPasswordService } from "../../auth/services/master-password/fake-master-password.service"; import { CsprngArray } from "../../types/csprng"; import { UserId } from "../../types/guid"; import { UserKey, MasterKey, PinKey } from "../../types/key"; @@ -18,6 +18,7 @@ import { Utils } from "../misc/utils"; import { EncString } from "../models/domain/enc-string"; import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; import { CryptoService } from "../services/crypto.service"; +import { UserKeyDefinition } from "../state"; import { USER_ENCRYPTED_ORGANIZATION_KEYS } from "./key-state/org-keys.state"; import { USER_ENCRYPTED_PROVIDER_KEYS } from "./key-state/provider-keys.state"; @@ -40,12 +41,15 @@ describe("cryptoService", () => { const mockUserId = Utils.newGuid() as UserId; let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; beforeEach(() => { accountService = mockAccountServiceWith(mockUserId); + masterPasswordService = new FakeMasterPasswordService(); stateProvider = new FakeStateProvider(accountService); cryptoService = new CryptoService( + masterPasswordService, keyGenerationService, cryptoFunctionService, encryptService, @@ -157,14 +161,14 @@ describe("cryptoService", () => { describe("getUserKeyWithLegacySupport", () => { let mockUserKey: UserKey; let mockMasterKey: MasterKey; - let stateSvcGetMasterKey: jest.SpyInstance; + let getMasterKey: jest.SpyInstance; beforeEach(() => { const mockRandomBytes = new Uint8Array(64) as CsprngArray; mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey; mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as MasterKey; - stateSvcGetMasterKey = jest.spyOn(stateService, "getMasterKey"); + getMasterKey = jest.spyOn(masterPasswordService, "masterKey$"); }); it("returns the User Key if available", async () => { @@ -174,17 +178,17 @@ describe("cryptoService", () => { const userKey = await cryptoService.getUserKeyWithLegacySupport(mockUserId); expect(getKeySpy).toHaveBeenCalledWith(mockUserId); - expect(stateSvcGetMasterKey).not.toHaveBeenCalled(); + expect(getMasterKey).not.toHaveBeenCalled(); expect(userKey).toEqual(mockUserKey); }); it("returns the user's master key when User Key is not available", async () => { - stateSvcGetMasterKey.mockResolvedValue(mockMasterKey); + masterPasswordService.masterKeySubject.next(mockMasterKey); const userKey = await cryptoService.getUserKeyWithLegacySupport(mockUserId); - expect(stateSvcGetMasterKey).toHaveBeenCalledWith({ userId: mockUserId }); + expect(getMasterKey).toHaveBeenCalledWith(mockUserId); expect(userKey).toEqual(mockMasterKey); }); }); @@ -268,15 +272,6 @@ describe("cryptoService", () => { await expect(cryptoService.setUserKey(null, mockUserId)).rejects.toThrow("No key provided."); }); - it("should update the user's lock state", async () => { - await cryptoService.setUserKey(mockUserKey, mockUserId); - - expect(accountService.mock.setAccountStatus).toHaveBeenCalledWith( - mockUserId, - AuthenticationStatus.Unlocked, - ); - }); - describe("Pin Key refresh", () => { let cryptoSvcMakePinKey: jest.SpyInstance; const protectedPin = @@ -336,249 +331,40 @@ describe("cryptoService", () => { }); }); - describe("clearUserKey", () => { - it.each([mockUserId, null])("should clear the User Key for id %2", async (userId) => { - await cryptoService.clearUserKey(false, userId); + describe("clearKeys", () => { + it("resolves active user id when called with no user id", async () => { + let callCount = 0; + stateProvider.activeUserId$ = stateProvider.activeUserId$.pipe(tap(() => callCount++)); - expect(stateProvider.mock.setUserState).toHaveBeenCalledWith(USER_KEY, null, userId); + await cryptoService.clearKeys(null); + expect(callCount).toBe(1); + + // revert to the original state + accountService.activeAccount$ = accountService.activeAccountSubject.asObservable(); }); - it("should update status to locked", async () => { - await cryptoService.clearUserKey(false, mockUserId); + describe.each([ + USER_ENCRYPTED_ORGANIZATION_KEYS, + USER_ENCRYPTED_PROVIDER_KEYS, + USER_ENCRYPTED_PRIVATE_KEY, + USER_KEY, + ])("key removal", (key: UserKeyDefinition) => { + it(`clears ${key.key} for active user when unspecified`, async () => { + await cryptoService.clearKeys(null); - expect(accountService.mock.setMaxAccountStatus).toHaveBeenCalledWith( - mockUserId, - AuthenticationStatus.Locked, - ); - }); + const encryptedOrgKeyState = stateProvider.singleUser.getFake(mockUserId, key); + expect(encryptedOrgKeyState.nextMock).toHaveBeenCalledTimes(1); + expect(encryptedOrgKeyState.nextMock).toHaveBeenCalledWith(null); + }); - it.each([true, false])( - "should clear stored user keys if clearAll is true (%s)", - async (clear) => { - const clearSpy = (cryptoService["clearAllStoredUserKeys"] = jest.fn()); - await cryptoService.clearUserKey(clear, mockUserId); + it(`clears ${key.key} for the specified user when specified`, async () => { + const userId = "someOtherUser" as UserId; + await cryptoService.clearKeys(userId); - if (clear) { - expect(clearSpy).toHaveBeenCalledWith(mockUserId); - expect(clearSpy).toHaveBeenCalledTimes(1); - } else { - expect(clearSpy).not.toHaveBeenCalled(); - } - }, - ); - }); - - describe("clearOrgKeys", () => { - let forceMemorySpy: jest.Mock; - beforeEach(() => { - forceMemorySpy = cryptoService["activeUserOrgKeysState"].forceValue = jest.fn(); - }); - it("clears in memory org keys when called with memoryOnly", async () => { - await cryptoService.clearOrgKeys(true); - - expect(forceMemorySpy).toHaveBeenCalledWith({}); - }); - - it("does not clear memory when called with the non active user and memory only", async () => { - await cryptoService.clearOrgKeys(true, "someOtherUser" as UserId); - - expect(forceMemorySpy).not.toHaveBeenCalled(); - }); - - it("does not write to disk state if called with memory only", async () => { - await cryptoService.clearOrgKeys(true); - - expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalled(); - }); - - it("clears disk state when called with diskOnly", async () => { - await cryptoService.clearOrgKeys(false); - - expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith( - mockUserId, - USER_ENCRYPTED_ORGANIZATION_KEYS, - ); - expect( - stateProvider.singleUser.getFake(mockUserId, USER_ENCRYPTED_ORGANIZATION_KEYS).nextMock, - ).toHaveBeenCalledWith(null); - }); - - it("clears another user's disk state when called with diskOnly and that user", async () => { - await cryptoService.clearOrgKeys(false, "someOtherUser" as UserId); - - expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith( - "someOtherUser" as UserId, - USER_ENCRYPTED_ORGANIZATION_KEYS, - ); - expect( - stateProvider.singleUser.getFake( - "someOtherUser" as UserId, - USER_ENCRYPTED_ORGANIZATION_KEYS, - ).nextMock, - ).toHaveBeenCalledWith(null); - }); - - it("does not clear active user disk state when called with diskOnly and a different specified user", async () => { - await cryptoService.clearOrgKeys(false, "someOtherUser" as UserId); - - expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalledWith( - mockUserId, - USER_ENCRYPTED_ORGANIZATION_KEYS, - ); - }); - }); - - describe("clearProviderKeys", () => { - let forceMemorySpy: jest.Mock; - beforeEach(() => { - forceMemorySpy = cryptoService["activeUserProviderKeysState"].forceValue = jest.fn(); - }); - it("clears in memory org keys when called with memoryOnly", async () => { - await cryptoService.clearProviderKeys(true); - - expect(forceMemorySpy).toHaveBeenCalledWith({}); - }); - - it("does not clear memory when called with the non active user and memory only", async () => { - await cryptoService.clearProviderKeys(true, "someOtherUser" as UserId); - - expect(forceMemorySpy).not.toHaveBeenCalled(); - }); - - it("does not write to disk state if called with memory only", async () => { - await cryptoService.clearProviderKeys(true); - - expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalled(); - }); - - it("clears disk state when called with diskOnly", async () => { - await cryptoService.clearProviderKeys(false); - - expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith( - mockUserId, - USER_ENCRYPTED_PROVIDER_KEYS, - ); - expect( - stateProvider.singleUser.getFake(mockUserId, USER_ENCRYPTED_PROVIDER_KEYS).nextMock, - ).toHaveBeenCalledWith(null); - }); - - it("clears another user's disk state when called with diskOnly and that user", async () => { - await cryptoService.clearProviderKeys(false, "someOtherUser" as UserId); - - expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith( - "someOtherUser" as UserId, - USER_ENCRYPTED_PROVIDER_KEYS, - ); - expect( - stateProvider.singleUser.getFake("someOtherUser" as UserId, USER_ENCRYPTED_PROVIDER_KEYS) - .nextMock, - ).toHaveBeenCalledWith(null); - }); - - it("does not clear active user disk state when called with diskOnly and a different specified user", async () => { - await cryptoService.clearProviderKeys(false, "someOtherUser" as UserId); - - expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalledWith( - mockUserId, - USER_ENCRYPTED_PROVIDER_KEYS, - ); - }); - }); - - describe("clearKeyPair", () => { - let forceMemoryPrivateKeySpy: jest.Mock; - let forceMemoryPublicKeySpy: jest.Mock; - beforeEach(() => { - forceMemoryPrivateKeySpy = cryptoService["activeUserPrivateKeyState"].forceValue = jest.fn(); - forceMemoryPublicKeySpy = cryptoService["activeUserPublicKeyState"].forceValue = jest.fn(); - }); - it("clears in memory org keys when called with memoryOnly", async () => { - await cryptoService.clearKeyPair(true); - - expect(forceMemoryPrivateKeySpy).toHaveBeenCalledWith(null); - expect(forceMemoryPublicKeySpy).toHaveBeenCalledWith(null); - }); - - it("does not clear memory when called with the non active user and memory only", async () => { - await cryptoService.clearKeyPair(true, "someOtherUser" as UserId); - - expect(forceMemoryPrivateKeySpy).not.toHaveBeenCalled(); - expect(forceMemoryPublicKeySpy).not.toHaveBeenCalled(); - }); - - it("does not write to disk state if called with memory only", async () => { - await cryptoService.clearKeyPair(true); - - expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalled(); - }); - - it("clears disk state when called with diskOnly", async () => { - await cryptoService.clearKeyPair(false); - - expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith( - mockUserId, - USER_ENCRYPTED_PRIVATE_KEY, - ); - expect( - stateProvider.singleUser.getFake(mockUserId, USER_ENCRYPTED_PRIVATE_KEY).nextMock, - ).toHaveBeenCalledWith(null); - }); - - it("clears another user's disk state when called with diskOnly and that user", async () => { - await cryptoService.clearKeyPair(false, "someOtherUser" as UserId); - - expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith( - "someOtherUser" as UserId, - USER_ENCRYPTED_PRIVATE_KEY, - ); - expect( - stateProvider.singleUser.getFake("someOtherUser" as UserId, USER_ENCRYPTED_PRIVATE_KEY) - .nextMock, - ).toHaveBeenCalledWith(null); - }); - - it("does not clear active user disk state when called with diskOnly and a different specified user", async () => { - await cryptoService.clearKeyPair(false, "someOtherUser" as UserId); - - expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalledWith( - mockUserId, - USER_ENCRYPTED_PRIVATE_KEY, - ); - }); - }); - - describe("clearUserKey", () => { - it("clears the user key for the active user when no userId is specified", async () => { - await cryptoService.clearUserKey(false); - expect(stateProvider.mock.setUserState).toHaveBeenCalledWith(USER_KEY, null, undefined); - }); - - it("clears the user key for the specified user when a userId is specified", async () => { - await cryptoService.clearUserKey(false, "someOtherUser" as UserId); - expect(stateProvider.mock.setUserState).toHaveBeenCalledWith(USER_KEY, null, "someOtherUser"); - }); - - it("sets the maximum account status of the active user id to locked when user id is not specified", async () => { - await cryptoService.clearUserKey(false); - expect(accountService.mock.setMaxAccountStatus).toHaveBeenCalledWith( - mockUserId, - AuthenticationStatus.Locked, - ); - }); - - it("sets the maximum account status of the specified user id to locked when user id is specified", async () => { - await cryptoService.clearUserKey(false, "someOtherUser" as UserId); - expect(accountService.mock.setMaxAccountStatus).toHaveBeenCalledWith( - "someOtherUser" as UserId, - AuthenticationStatus.Locked, - ); - }); - - it("clears all stored user keys when clearAll is true", async () => { - const clearAllSpy = (cryptoService["clearAllStoredUserKeys"] = jest.fn()); - await cryptoService.clearUserKey(true); - expect(clearAllSpy).toHaveBeenCalledWith(mockUserId); + const encryptedOrgKeyState = stateProvider.singleUser.getFake(userId, key); + expect(encryptedOrgKeyState.nextMock).toHaveBeenCalledTimes(1); + expect(encryptedOrgKeyState.nextMock).toHaveBeenCalledWith(null); + }); }); }); }); diff --git a/libs/common/src/platform/services/crypto.service.ts b/libs/common/src/platform/services/crypto.service.ts index dd3c497470..c091b6a5a9 100644 --- a/libs/common/src/platform/services/crypto.service.ts +++ b/libs/common/src/platform/services/crypto.service.ts @@ -6,7 +6,7 @@ import { ProfileOrganizationResponse } from "../../admin-console/models/response import { ProfileProviderOrganizationResponse } from "../../admin-console/models/response/profile-provider-organization.response"; import { ProfileProviderResponse } from "../../admin-console/models/response/profile-provider.response"; import { AccountService } from "../../auth/abstractions/account.service"; -import { AuthenticationStatus } from "../../auth/enums/authentication-status"; +import { InternalMasterPasswordServiceAbstraction } from "../../auth/abstractions/master-password.service.abstraction"; import { KdfConfig } from "../../auth/models/domain/kdf-config"; import { Utils } from "../../platform/misc/utils"; import { CsprngArray } from "../../types/csprng"; @@ -82,6 +82,7 @@ export class CryptoService implements CryptoServiceAbstraction { readonly everHadUserKey$: Observable; constructor( + protected masterPasswordService: InternalMasterPasswordServiceAbstraction, protected keyGenerationService: KeyGenerationService, protected cryptoFunctionService: CryptoFunctionService, protected encryptService: EncryptService, @@ -144,14 +145,12 @@ export class CryptoService implements CryptoServiceAbstraction { async setUserKey(key: UserKey, userId?: UserId): Promise { if (key == null) { - throw new Error("No key provided. Use ClearUserKey to clear the key"); + throw new Error("No key provided. Lock the user to clear the key"); } // Set userId to ensure we have one for the account status update [userId, key] = await this.stateProvider.setUserState(USER_KEY, key, userId); await this.stateProvider.setUserState(USER_EVER_HAD_USER_KEY, true, userId); - await this.accountService.setAccountStatus(userId, AuthenticationStatus.Unlocked); - await this.storeAdditionalKeys(key, userId); } @@ -181,12 +180,16 @@ export class CryptoService implements CryptoServiceAbstraction { } async isLegacyUser(masterKey?: MasterKey, userId?: UserId): Promise { - return await this.validateUserKey( - (masterKey ?? (await this.getMasterKey(userId))) as unknown as UserKey, - ); + userId ??= await firstValueFrom(this.stateProvider.activeUserId$); + masterKey ??= await firstValueFrom(this.masterPasswordService.masterKey$(userId)); + + return await this.validateUserKey(masterKey as unknown as UserKey); } + // TODO: legacy support for user key is no longer needed since we require users to migrate on login async getUserKeyWithLegacySupport(userId?: UserId): Promise { + userId ??= await firstValueFrom(this.stateProvider.activeUserId$); + const userKey = await this.getUserKey(userId); if (userKey) { return userKey; @@ -194,7 +197,8 @@ export class CryptoService implements CryptoServiceAbstraction { // Legacy support: encryption used to be done with the master key (derived from master password). // Users who have not migrated will have a null user key and must use the master key instead. - return (await this.getMasterKey(userId)) as unknown as UserKey; + const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); + return masterKey as unknown as UserKey; } async getUserKeyFromStorage(keySuffix: KeySuffixOptions, userId?: UserId): Promise { @@ -233,7 +237,10 @@ export class CryptoService implements CryptoServiceAbstraction { } async makeUserKey(masterKey: MasterKey): Promise<[UserKey, EncString]> { - masterKey ||= await this.getMasterKey(); + if (!masterKey) { + const userId = await firstValueFrom(this.stateProvider.activeUserId$); + masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); + } if (masterKey == null) { throw new Error("No Master Key found."); } @@ -242,13 +249,18 @@ export class CryptoService implements CryptoServiceAbstraction { return this.buildProtectedSymmetricKey(masterKey, newUserKey.key); } - async clearUserKey(clearStoredKeys = true, userId?: UserId): Promise { - // Set userId to ensure we have one for the account status update - [userId] = await this.stateProvider.setUserState(USER_KEY, null, userId); - await this.accountService.setMaxAccountStatus(userId, AuthenticationStatus.Locked); - if (clearStoredKeys) { - await this.clearAllStoredUserKeys(userId); + /** + * Clears the user key. Clears all stored versions of the user keys as well, such as the biometrics key + * @param userId The desired user + */ + private async clearUserKey(userId: UserId): Promise { + if (userId == null) { + // nothing to do + return; } + // Set userId to ensure we have one for the account status update + await this.stateProvider.setUserState(USER_KEY, null, userId); + await this.clearAllStoredUserKeys(userId); } async clearStoredUserKey(keySuffix: KeySuffixOptions, userId?: UserId): Promise { @@ -271,28 +283,17 @@ export class CryptoService implements CryptoServiceAbstraction { } async setMasterKeyEncryptedUserKey(userKeyMasterKey: string, userId?: UserId): Promise { - await this.stateService.setMasterKeyEncryptedUserKey(userKeyMasterKey, { userId: userId }); - } - - async setMasterKey(key: MasterKey, userId?: UserId): Promise { - await this.stateService.setMasterKey(key, { userId: userId }); - } - - async getMasterKey(userId?: UserId): Promise { - let masterKey = await this.stateService.getMasterKey({ userId: userId }); - if (!masterKey) { - masterKey = (await this.stateService.getCryptoMasterKey({ userId: userId })) as MasterKey; - // if master key was null/undefined and getCryptoMasterKey also returned null/undefined, - // don't set master key as it is unnecessary - if (masterKey) { - await this.setMasterKey(masterKey, userId); - } - } - return masterKey; + userId ??= await firstValueFrom(this.stateProvider.activeUserId$); + await this.masterPasswordService.setMasterKeyEncryptedUserKey( + new EncString(userKeyMasterKey), + userId, + ); } + // TODO: Move to MasterPasswordService async getOrDeriveMasterKey(password: string, userId?: UserId) { - let masterKey = await this.getMasterKey(userId); + userId ??= await firstValueFrom(this.stateProvider.activeUserId$); + let masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); return (masterKey ||= await this.makeMasterKey( password, await this.stateService.getEmail({ userId: userId }), @@ -306,6 +307,7 @@ export class CryptoService implements CryptoServiceAbstraction { * * @remarks * Does not validate the kdf config to ensure it satisfies the minimum requirements for the given kdf type. + * TODO: Move to MasterPasswordService */ async makeMasterKey( password: string, @@ -321,10 +323,6 @@ export class CryptoService implements CryptoServiceAbstraction { )) as MasterKey; } - async clearMasterKey(userId?: UserId): Promise { - await this.stateService.setMasterKey(null, { userId: userId }); - } - async encryptUserKeyWithMasterKey( masterKey: MasterKey, userKey?: UserKey, @@ -333,32 +331,28 @@ export class CryptoService implements CryptoServiceAbstraction { return await this.buildProtectedSymmetricKey(masterKey, userKey.key); } + // TODO: move to master password service async decryptUserKeyWithMasterKey( masterKey: MasterKey, userKey?: EncString, userId?: UserId, ): Promise { - masterKey ||= await this.getMasterKey(userId); + userId ??= await firstValueFrom(this.stateProvider.activeUserId$); + userKey ??= await this.masterPasswordService.getMasterKeyEncryptedUserKey(userId); + masterKey ??= await firstValueFrom(this.masterPasswordService.masterKey$(userId)); if (masterKey == null) { throw new Error("No master key found."); } - if (!userKey) { - let masterKeyEncryptedUserKey = await this.stateService.getMasterKeyEncryptedUserKey({ + // Try one more way to get the user key if it still wasn't found. + if (userKey == null) { + const deprecatedKey = await this.stateService.getEncryptedCryptoSymmetricKey({ userId: userId, }); - - // Try one more way to get the user key if it still wasn't found. - if (masterKeyEncryptedUserKey == null) { - masterKeyEncryptedUserKey = await this.stateService.getEncryptedCryptoSymmetricKey({ - userId: userId, - }); - } - - if (masterKeyEncryptedUserKey == null) { + if (deprecatedKey == null) { throw new Error("No encrypted user key found."); } - userKey = new EncString(masterKeyEncryptedUserKey); + userKey = new EncString(deprecatedKey); } let decUserKey: Uint8Array; @@ -377,12 +371,16 @@ export class CryptoService implements CryptoServiceAbstraction { return new SymmetricCryptoKey(decUserKey) as UserKey; } + // TODO: move to MasterPasswordService async hashMasterKey( password: string, key: MasterKey, hashPurpose?: HashPurpose, ): Promise { - key ||= await this.getMasterKey(); + if (!key) { + const userId = await firstValueFrom(this.stateProvider.activeUserId$); + key = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); + } if (password == null || key == null) { throw new Error("Invalid parameters."); @@ -393,20 +391,12 @@ export class CryptoService implements CryptoServiceAbstraction { return Utils.fromBufferToB64(hash); } - async setMasterKeyHash(keyHash: string): Promise { - await this.stateService.setKeyHash(keyHash); - } - - async getMasterKeyHash(): Promise { - return await this.stateService.getKeyHash(); - } - - async clearMasterKeyHash(userId?: UserId): Promise { - return await this.stateService.setKeyHash(null, { userId: userId }); - } - + // TODO: move to MasterPasswordService async compareAndUpdateKeyHash(masterPassword: string, masterKey: MasterKey): Promise { - const storedPasswordHash = await this.getMasterKeyHash(); + const userId = await firstValueFrom(this.stateProvider.activeUserId$); + const storedPasswordHash = await firstValueFrom( + this.masterPasswordService.masterKeyHash$(userId), + ); if (masterPassword != null && storedPasswordHash != null) { const localKeyHash = await this.hashMasterKey( masterPassword, @@ -424,7 +414,7 @@ export class CryptoService implements CryptoServiceAbstraction { HashPurpose.ServerAuthorization, ); if (serverKeyHash != null && storedPasswordHash === serverKeyHash) { - await this.setMasterKeyHash(localKeyHash); + await this.masterPasswordService.setMasterKeyHash(localKeyHash, userId); return true; } } @@ -480,25 +470,12 @@ export class CryptoService implements CryptoServiceAbstraction { return this.buildProtectedSymmetricKey(key, newSymKey.key); } - async clearOrgKeys(memoryOnly?: boolean, userId?: UserId): Promise { - const activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id; - const userIdIsActive = userId == null || userId === activeUserId; - - if (!memoryOnly) { - if (userId == null && activeUserId == null) { - // nothing to do - return; - } - await this.stateProvider - .getUser(userId ?? activeUserId, USER_ENCRYPTED_ORGANIZATION_KEYS) - .update(() => null); + private async clearOrgKeys(userId: UserId): Promise { + if (userId == null) { + // nothing to do return; } - - // org keys are only cached for active users - if (userIdIsActive) { - await this.activeUserOrgKeysState.forceValue({}); - } + await this.stateProvider.setUserState(USER_ENCRYPTED_ORGANIZATION_KEYS, null, userId); } async setProviderKeys(providers: ProfileProviderResponse[]): Promise { @@ -526,25 +503,12 @@ export class CryptoService implements CryptoServiceAbstraction { return await firstValueFrom(this.activeUserProviderKeys$); } - async clearProviderKeys(memoryOnly?: boolean, userId?: UserId): Promise { - const activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id; - const userIdIsActive = userId == null || userId === activeUserId; - - if (!memoryOnly) { - if (userId == null && activeUserId == null) { - // nothing to do - return; - } - await this.stateProvider - .getUser(userId ?? activeUserId, USER_ENCRYPTED_PROVIDER_KEYS) - .update(() => null); + private async clearProviderKeys(userId: UserId): Promise { + if (userId == null) { + // nothing to do return; } - - // provider keys are only cached for active users - if (userIdIsActive) { - await this.activeUserProviderKeysState.forceValue({}); - } + await this.stateProvider.setUserState(USER_ENCRYPTED_PROVIDER_KEYS, null, userId); } async getPublicKey(): Promise { @@ -597,26 +561,17 @@ export class CryptoService implements CryptoServiceAbstraction { return [publicB64, privateEnc]; } - async clearKeyPair(memoryOnly?: boolean, userId?: UserId): Promise { - const activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id; - const userIdIsActive = userId == null || userId === activeUserId; - - if (!memoryOnly) { - if (userId == null && activeUserId == null) { - // nothing to do - return; - } - await this.stateProvider - .getUser(userId ?? activeUserId, USER_ENCRYPTED_PRIVATE_KEY) - .update(() => null); + /** + * Clears the user's key pair + * @param userId The desired user + */ + private async clearKeyPair(userId: UserId): Promise { + if (userId == null) { + // nothing to do return; } - // decrypted key pair is only cached for active users - if (userIdIsActive) { - await this.activeUserPrivateKeyState.forceValue(null); - await this.activeUserPublicKeyState.forceValue(null); - } + await this.stateProvider.setUserState(USER_ENCRYPTED_PRIVATE_KEY, null, userId); } async makePinKey(pin: string, salt: string, kdf: KdfType, kdfConfig: KdfConfig): Promise { @@ -681,11 +636,17 @@ export class CryptoService implements CryptoServiceAbstraction { } async clearKeys(userId?: UserId): Promise { - await this.clearUserKey(true, userId); - await this.clearMasterKeyHash(userId); - await this.clearOrgKeys(false, userId); - await this.clearProviderKeys(false, userId); - await this.clearKeyPair(false, userId); + userId ??= await firstValueFrom(this.stateProvider.activeUserId$); + + if (userId == null) { + throw new Error("Cannot clear keys, no user Id resolved."); + } + + await this.masterPasswordService.clearMasterKeyHash(userId); + await this.clearUserKey(userId); + await this.clearOrgKeys(userId); + await this.clearProviderKeys(userId); + await this.clearKeyPair(userId); await this.clearPinKeys(userId); await this.stateProvider.setUserState(USER_EVER_HAD_USER_KEY, null, userId); } @@ -1037,7 +998,8 @@ export class CryptoService implements CryptoServiceAbstraction { if (await this.isLegacyUser(masterKey, userId)) { // Legacy users don't have a user key, so no need to migrate. // Instead, set the master key for additional isLegacyUser checks that will log the user out. - await this.setMasterKey(masterKey, userId); + userId ??= await firstValueFrom(this.stateProvider.activeUserId$); + await this.masterPasswordService.setMasterKey(masterKey, userId); return; } const encryptedUserKey = await this.stateService.getEncryptedCryptoSymmetricKey({ diff --git a/libs/common/src/platform/services/default-environment.service.spec.ts b/libs/common/src/platform/services/default-environment.service.spec.ts index 66bc1acfda..dd504dc302 100644 --- a/libs/common/src/platform/services/default-environment.service.spec.ts +++ b/libs/common/src/platform/services/default-environment.service.spec.ts @@ -2,14 +2,14 @@ import { firstValueFrom } from "rxjs"; import { FakeStateProvider, awaitAsync } from "../../../spec"; import { FakeAccountService } from "../../../spec/fake-account-service"; -import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { UserId } from "../../types/guid"; import { CloudRegion, Region } from "../abstractions/environment.service"; import { - ENVIRONMENT_KEY, + GLOBAL_ENVIRONMENT_KEY, DefaultEnvironmentService, EnvironmentUrls, + USER_ENVIRONMENT_KEY, } from "./default-environment.service"; // There are a few main states EnvironmentService could be in when first used @@ -31,12 +31,10 @@ describe("EnvironmentService", () => { [testUser]: { name: "name", email: "email", - status: AuthenticationStatus.Locked, }, [alternateTestUser]: { name: "name", email: "email", - status: AuthenticationStatus.Locked, }, }); stateProvider = new FakeStateProvider(accountService); @@ -49,13 +47,12 @@ describe("EnvironmentService", () => { id: userId, email: "test@example.com", name: `Test Name ${userId}`, - status: AuthenticationStatus.Unlocked, }); await awaitAsync(); }; const setGlobalData = (region: Region, environmentUrls: EnvironmentUrls) => { - stateProvider.global.getFake(ENVIRONMENT_KEY).stateSubject.next({ + stateProvider.global.getFake(GLOBAL_ENVIRONMENT_KEY).stateSubject.next({ region: region, urls: environmentUrls, }); @@ -66,7 +63,7 @@ describe("EnvironmentService", () => { environmentUrls: EnvironmentUrls, userId: UserId = testUser, ) => { - stateProvider.singleUser.getFake(userId, ENVIRONMENT_KEY).nextState({ + stateProvider.singleUser.getFake(userId, USER_ENVIRONMENT_KEY).nextState({ region: region, urls: environmentUrls, }); diff --git a/libs/common/src/platform/services/default-environment.service.ts b/libs/common/src/platform/services/default-environment.service.ts index d074ff43f8..59956ede7a 100644 --- a/libs/common/src/platform/services/default-environment.service.ts +++ b/libs/common/src/platform/services/default-environment.service.ts @@ -18,6 +18,7 @@ import { GlobalState, KeyDefinition, StateProvider, + UserKeyDefinition, } from "../state"; export class EnvironmentUrls { @@ -40,7 +41,7 @@ class EnvironmentState { } } -export const ENVIRONMENT_KEY = new KeyDefinition( +export const GLOBAL_ENVIRONMENT_KEY = new KeyDefinition( ENVIRONMENT_DISK, "environment", { @@ -48,9 +49,31 @@ export const ENVIRONMENT_KEY = new KeyDefinition( }, ); -export const CLOUD_REGION_KEY = new KeyDefinition(ENVIRONMENT_MEMORY, "cloudRegion", { - deserializer: (b) => b, -}); +export const USER_ENVIRONMENT_KEY = new UserKeyDefinition( + ENVIRONMENT_DISK, + "environment", + { + deserializer: EnvironmentState.fromJSON, + clearOn: ["logout"], + }, +); + +export const GLOBAL_CLOUD_REGION_KEY = new KeyDefinition( + ENVIRONMENT_MEMORY, + "cloudRegion", + { + deserializer: (b) => b, + }, +); + +export const USER_CLOUD_REGION_KEY = new UserKeyDefinition( + ENVIRONMENT_MEMORY, + "cloudRegion", + { + deserializer: (b) => b, + clearOn: ["logout"], + }, +); /** * The production regions available for selection. @@ -114,8 +137,8 @@ export class DefaultEnvironmentService implements EnvironmentService { private stateProvider: StateProvider, private accountService: AccountService, ) { - this.globalState = this.stateProvider.getGlobal(ENVIRONMENT_KEY); - this.globalCloudRegionState = this.stateProvider.getGlobal(CLOUD_REGION_KEY); + this.globalState = this.stateProvider.getGlobal(GLOBAL_ENVIRONMENT_KEY); + this.globalCloudRegionState = this.stateProvider.getGlobal(GLOBAL_CLOUD_REGION_KEY); const account$ = this.activeAccountId$.pipe( // Use == here to not trigger on undefined -> null transition @@ -125,8 +148,8 @@ export class DefaultEnvironmentService implements EnvironmentService { this.environment$ = account$.pipe( switchMap((userId) => { const t = userId - ? this.stateProvider.getUser(userId, ENVIRONMENT_KEY).state$ - : this.stateProvider.getGlobal(ENVIRONMENT_KEY).state$; + ? this.stateProvider.getUser(userId, USER_ENVIRONMENT_KEY).state$ + : this.stateProvider.getGlobal(GLOBAL_ENVIRONMENT_KEY).state$; return t; }), map((state) => { @@ -136,8 +159,8 @@ export class DefaultEnvironmentService implements EnvironmentService { this.cloudWebVaultUrl$ = account$.pipe( switchMap((userId) => { const t = userId - ? this.stateProvider.getUser(userId, CLOUD_REGION_KEY).state$ - : this.stateProvider.getGlobal(CLOUD_REGION_KEY).state$; + ? this.stateProvider.getUser(userId, USER_CLOUD_REGION_KEY).state$ + : this.stateProvider.getGlobal(GLOBAL_CLOUD_REGION_KEY).state$; return t; }), map((region) => { @@ -242,7 +265,7 @@ export class DefaultEnvironmentService implements EnvironmentService { if (userId == null) { await this.globalCloudRegionState.update(() => region); } else { - await this.stateProvider.getUser(userId, CLOUD_REGION_KEY).update(() => region); + await this.stateProvider.getUser(userId, USER_CLOUD_REGION_KEY).update(() => region); } } @@ -261,13 +284,13 @@ export class DefaultEnvironmentService implements EnvironmentService { return activeUserId == null ? await firstValueFrom(this.globalState.state$) : await firstValueFrom( - this.stateProvider.getUser(userId ?? activeUserId, ENVIRONMENT_KEY).state$, + this.stateProvider.getUser(userId ?? activeUserId, USER_ENVIRONMENT_KEY).state$, ); } async seedUserEnvironment(userId: UserId) { const global = await firstValueFrom(this.globalState.state$); - await this.stateProvider.getUser(userId, ENVIRONMENT_KEY).update(() => global); + await this.stateProvider.getUser(userId, USER_ENVIRONMENT_KEY).update(() => global); } } diff --git a/libs/common/src/platform/services/key-state/org-keys.state.ts b/libs/common/src/platform/services/key-state/org-keys.state.ts index b39cc9a82a..f67e64b653 100644 --- a/libs/common/src/platform/services/key-state/org-keys.state.ts +++ b/libs/common/src/platform/services/key-state/org-keys.state.ts @@ -4,13 +4,14 @@ import { OrganizationId } from "../../../types/guid"; import { OrgKey } from "../../../types/key"; import { CryptoService } from "../../abstractions/crypto.service"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; -import { KeyDefinition, CRYPTO_DISK, DeriveDefinition } from "../../state"; +import { CRYPTO_DISK, DeriveDefinition, UserKeyDefinition } from "../../state"; -export const USER_ENCRYPTED_ORGANIZATION_KEYS = KeyDefinition.record< +export const USER_ENCRYPTED_ORGANIZATION_KEYS = UserKeyDefinition.record< EncryptedOrganizationKeyData, OrganizationId >(CRYPTO_DISK, "organizationKeys", { deserializer: (obj) => obj, + clearOn: ["logout"], }); export const USER_ORGANIZATION_KEYS = DeriveDefinition.from< diff --git a/libs/common/src/platform/services/key-state/provider-keys.state.ts b/libs/common/src/platform/services/key-state/provider-keys.state.ts index c89df34c80..776fdc77d8 100644 --- a/libs/common/src/platform/services/key-state/provider-keys.state.ts +++ b/libs/common/src/platform/services/key-state/provider-keys.state.ts @@ -3,14 +3,15 @@ import { ProviderKey } from "../../../types/key"; import { EncryptService } from "../../abstractions/encrypt.service"; import { EncString, EncryptedString } from "../../models/domain/enc-string"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; -import { KeyDefinition, CRYPTO_DISK, DeriveDefinition } from "../../state"; +import { CRYPTO_DISK, DeriveDefinition, UserKeyDefinition } from "../../state"; import { CryptoService } from "../crypto.service"; -export const USER_ENCRYPTED_PROVIDER_KEYS = KeyDefinition.record( +export const USER_ENCRYPTED_PROVIDER_KEYS = UserKeyDefinition.record( CRYPTO_DISK, "providerKeys", { deserializer: (obj) => obj, + clearOn: ["logout"], }, ); diff --git a/libs/common/src/platform/services/key-state/user-key.state.ts b/libs/common/src/platform/services/key-state/user-key.state.ts index d0f54c9add..609525b0ac 100644 --- a/libs/common/src/platform/services/key-state/user-key.state.ts +++ b/libs/common/src/platform/services/key-state/user-key.state.ts @@ -3,18 +3,24 @@ import { CryptoFunctionService } from "../../abstractions/crypto-function.servic import { EncryptService } from "../../abstractions/encrypt.service"; import { EncString, EncryptedString } from "../../models/domain/enc-string"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; -import { KeyDefinition, CRYPTO_DISK, DeriveDefinition, CRYPTO_MEMORY } from "../../state"; +import { CRYPTO_DISK, DeriveDefinition, CRYPTO_MEMORY, UserKeyDefinition } from "../../state"; import { CryptoService } from "../crypto.service"; -export const USER_EVER_HAD_USER_KEY = new KeyDefinition(CRYPTO_DISK, "everHadUserKey", { - deserializer: (obj) => obj, -}); +export const USER_EVER_HAD_USER_KEY = new UserKeyDefinition( + CRYPTO_DISK, + "everHadUserKey", + { + deserializer: (obj) => obj, + clearOn: ["logout"], + }, +); -export const USER_ENCRYPTED_PRIVATE_KEY = new KeyDefinition( +export const USER_ENCRYPTED_PRIVATE_KEY = new UserKeyDefinition( CRYPTO_DISK, "privateKey", { deserializer: (obj) => obj, + clearOn: ["logout"], }, ); @@ -58,6 +64,7 @@ export const USER_PUBLIC_KEY = DeriveDefinition.from< return (await cryptoFunctionService.rsaExtractPublicKey(privateKey)) as UserPublicKey; }, }); -export const USER_KEY = new KeyDefinition(CRYPTO_MEMORY, "userKey", { +export const USER_KEY = new UserKeyDefinition(CRYPTO_MEMORY, "userKey", { deserializer: (obj) => SymmetricCryptoKey.fromJSON(obj) as UserKey, + clearOn: ["logout", "lock"], }); diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index a35659a7ac..9edc9ed1e3 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -1,22 +1,14 @@ -import { BehaviorSubject, Observable, map } from "rxjs"; +import { BehaviorSubject } from "rxjs"; import { Jsonify, JsonValue } from "type-fest"; import { AccountService } from "../../auth/abstractions/account.service"; import { TokenService } from "../../auth/abstractions/token.service"; -import { AuthenticationStatus } from "../../auth/enums/authentication-status"; -import { AdminAuthRequestStorable } from "../../auth/models/domain/admin-auth-req-storable"; -import { ForceSetPasswordReason } from "../../auth/models/domain/force-set-password-reason"; import { KdfConfig } from "../../auth/models/domain/kdf-config"; import { BiometricKey } from "../../auth/types/biometric-key"; import { GeneratorOptions } from "../../tools/generator/generator-options"; import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password"; import { UsernameGeneratorOptions } from "../../tools/generator/username"; import { UserId } from "../../types/guid"; -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"; -import { AddEditCipherInfo } from "../../vault/types/add-edit-cipher-info"; import { EnvironmentService } from "../abstractions/environment.service"; import { LogService } from "../abstractions/log.service"; import { @@ -35,7 +27,6 @@ import { EncString } from "../models/domain/enc-string"; import { GlobalState } from "../models/domain/global-state"; import { State } from "../models/domain/state"; import { StorageOptions } from "../models/domain/storage-options"; -import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; import { MigrationRunner } from "./migration-runner"; @@ -71,8 +62,6 @@ export class StateService< protected activeAccountSubject = new BehaviorSubject(null); activeAccount$ = this.activeAccountSubject.asObservable(); - activeAccountUnlocked$: Observable; - private hasBeenInited = false; protected isRecoveredSession = false; @@ -92,13 +81,7 @@ export class StateService< protected tokenService: TokenService, private migrationRunner: MigrationRunner, protected useAccountCache: boolean = true, - ) { - this.activeAccountUnlocked$ = this.accountService.activeAccount$.pipe( - map((a) => { - return a?.status === AuthenticationStatus.Unlocked; - }), - ); - } + ) {} async init(initOptions: InitOptions = {}): Promise { // Deconstruct and apply defaults @@ -154,7 +137,6 @@ export class StateService< await this.accountService.addAccount(state.activeUserId as UserId, { name: activeDiskAccount.profile.name, email: activeDiskAccount.profile.email, - status: AuthenticationStatus.LoggedOut, }); } await this.accountService.switchAccount(state.activeUserId as UserId); @@ -180,16 +162,7 @@ export class StateService< // TODO: Temporary update to avoid routing all account status changes through account service for now. // The determination of state should be handled by the various services that control those values. - const token = await this.tokenService.getAccessToken(userId as UserId); - const autoKey = await this.getUserKeyAutoUnlock({ userId: userId }); - const accountStatus = - token == null - ? AuthenticationStatus.LoggedOut - : autoKey == null - ? AuthenticationStatus.Locked - : AuthenticationStatus.Unlocked; await this.accountService.addAccount(userId as UserId, { - status: accountStatus, name: diskAccount.profile.name, email: diskAccount.profile.email, }); @@ -209,7 +182,6 @@ export class StateService< await this.setLastActive(new Date().getTime(), { userId: account.profile.userId }); // TODO: Temporary update to avoid routing all account status changes through account service for now. await this.accountService.addAccount(account.profile.userId as UserId, { - status: AuthenticationStatus.Locked, name: account.profile.name, email: account.profile.email, }); @@ -245,93 +217,6 @@ export class StateService< return currentUser as UserId; } - async getAddEditCipherInfo(options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - // ensure prototype on cipher - const raw = account?.data?.addEditCipherInfo; - return raw == null - ? null - : { - cipher: - raw?.cipher.toJSON != null - ? raw.cipher - : CipherView.fromJSON(raw?.cipher as Jsonify), - collectionIds: raw?.collectionIds, - }; - } - - async setAddEditCipherInfo(value: AddEditCipherInfo, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - account.data.addEditCipherInfo = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - } - - /** - * @deprecated Do not save the Master Key. Use the User Symmetric Key instead - */ - async getCryptoMasterKey(options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - return account?.keys?.cryptoMasterKey; - } - - /** - * User's master key derived from MP, saved only if we decrypted with MP - */ - async getMasterKey(options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - return account?.keys?.masterKey; - } - - /** - * User's master key derived from MP, saved only if we decrypted with MP - */ - async setMasterKey(value: MasterKey, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - account.keys.masterKey = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - } - - /** - * The master key encrypted User symmetric key, saved on every auth - * so we can unlock with MP offline - */ - async getMasterKeyEncryptedUserKey(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.keys.masterKeyEncryptedUserKey; - } - - /** - * The master key encrypted User symmetric key, saved on every auth - * so we can unlock with MP offline - */ - async setMasterKeyEncryptedUserKey(value: string, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.keys.masterKeyEncryptedUserKey = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - /** * user key when using the "never" option of vault timeout */ @@ -548,24 +433,6 @@ export class StateService< await this.saveSecureStorageKey(partialKeys.biometricKey, value, options); } - @withPrototypeForArrayMembers(CipherView, CipherView.fromJSON) - async getDecryptedCiphers(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) - )?.data?.ciphers?.decrypted; - } - - async setDecryptedCiphers(value: CipherView[], options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - account.data.ciphers.decrypted = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - } - @withPrototypeForArrayMembers(GeneratedPasswordHistory) async getDecryptedPasswordGenerationHistory( options?: StorageOptions, @@ -630,37 +497,6 @@ export class StateService< : await this.secureStorageService.save(DDG_SHARED_KEY, value, options); } - async getAdminAuthRequest(options?: StorageOptions): Promise { - options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); - - if (options?.userId == null) { - return null; - } - - const account = await this.getAccount(options); - - return account?.adminAuthRequest - ? AdminAuthRequestStorable.fromJSON(account.adminAuthRequest) - : null; - } - - async setAdminAuthRequest( - adminAuthRequest: AdminAuthRequestStorable, - options?: StorageOptions, - ): Promise { - options = this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()); - - if (options?.userId == null) { - return; - } - - const account = await this.getAccount(options); - - account.adminAuthRequest = adminAuthRequest?.toJSON(); - - await this.saveAccount(account, options); - } - async getEmail(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) @@ -735,27 +571,6 @@ export class StateService< ); } - @withPrototypeForObjectValues(CipherData) - async getEncryptedCiphers(options?: StorageOptions): Promise<{ [id: string]: CipherData }> { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions())) - )?.data?.ciphers?.encrypted; - } - - async setEncryptedCiphers( - value: { [id: string]: CipherData }, - options?: StorageOptions, - ): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()), - ); - account.data.ciphers.encrypted = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()), - ); - } - /** * @deprecated Use UserKey instead */ @@ -823,30 +638,6 @@ export class StateService< ); } - async getForceSetPasswordReason(options?: StorageOptions): Promise { - return ( - ( - await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()), - ) - )?.profile?.forceSetPasswordReason ?? ForceSetPasswordReason.None - ); - } - - async setForceSetPasswordReason( - value: ForceSetPasswordReason, - options?: StorageOptions, - ): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()), - ); - account.profile.forceSetPasswordReason = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()), - ); - } - async getIsAuthenticated(options?: StorageOptions): Promise { return ( (await this.tokenService.getAccessToken(options?.userId as UserId)) != null && @@ -897,23 +688,6 @@ export class StateService< ); } - async getKeyHash(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.profile?.keyHash; - } - - async setKeyHash(value: string, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - account.profile.keyHash = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskOptions()), - ); - } - async getLastActive(options?: StorageOptions): Promise { options = this.reconcileOptions(options, await this.defaultOnDiskOptions()); @@ -960,26 +734,6 @@ export class StateService< ); } - async getLocalData(options?: StorageOptions): Promise<{ [cipherId: string]: LocalData }> { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) - )?.data?.localData; - } - - async setLocalData( - value: { [cipherId: string]: LocalData }, - options?: StorageOptions, - ): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ); - account.data.localData = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ); - } - async getMinimizeOnCopyToClipboard(options?: StorageOptions): Promise { return ( (await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) @@ -1155,24 +909,6 @@ export class StateService< ); } - async getApproveLoginRequests(options?: StorageOptions): Promise { - const approveLoginRequests = ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) - )?.settings?.approveLoginRequests; - return approveLoginRequests; - } - - async setApproveLoginRequests(value: boolean, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ); - account.settings.approveLoginRequests = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ); - } - protected async getGlobals(options: StorageOptions): Promise { let globals: TGlobalState; if (this.useMemory(options.storageLocation)) { @@ -1509,15 +1245,12 @@ export class StateService< return state; }); - // TODO: Invert this logic, we should remove accounts based on logged out emit - await this.accountService.setAccountStatus(userId as UserId, AuthenticationStatus.LoggedOut); } // settings persist even on reset, and are not affected by this method protected resetAccount(account: TAccount) { const persistentAccountInformation = { settings: account.settings, - adminAuthRequest: account.adminAuthRequest, }; return Object.assign(this.createAccount(), persistentAccountInformation); } @@ -1686,50 +1419,3 @@ function withPrototypeForArrayMembers( }; }; } - -function withPrototypeForObjectValues( - valuesConstructor: new (...args: any[]) => T, - valuesConverter: (input: any) => T = (i) => i, -): ( - target: any, - propertyKey: string | symbol, - descriptor: PropertyDescriptor, -) => { value: (...args: any[]) => Promise<{ [key: string]: T }> } { - return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { - const originalMethod = descriptor.value; - - return { - value: function (...args: any[]) { - const originalResult: Promise<{ [key: string]: T }> = originalMethod.apply(this, args); - - if (!Utils.isPromise(originalResult)) { - throw new Error( - `Error applying prototype to stored value -- result is not a promise for method ${String( - propertyKey, - )}`, - ); - } - - return originalResult.then((result) => { - if (result == null) { - return null; - } else { - for (const [key, val] of Object.entries(result)) { - result[key] = - val == null || val.constructor.name === valuesConstructor.prototype.constructor.name - ? valuesConverter(val) - : valuesConverter( - Object.create( - valuesConstructor.prototype, - Object.getOwnPropertyDescriptors(val), - ), - ); - } - - return result as { [key: string]: T }; - } - }); - }, - }; - }; -} diff --git a/libs/common/src/platform/state/derive-definition.ts b/libs/common/src/platform/state/derive-definition.ts index 6c514f8869..8f62d3a342 100644 --- a/libs/common/src/platform/state/derive-definition.ts +++ b/libs/common/src/platform/state/derive-definition.ts @@ -5,6 +5,7 @@ import { DerivedStateDependencies, StorageKey } from "../../types/state"; import { KeyDefinition } from "./key-definition"; import { StateDefinition } from "./state-definition"; +import { UserKeyDefinition } from "./user-key-definition"; declare const depShapeMarker: unique symbol; /** @@ -129,26 +130,28 @@ export class DeriveDefinition( definition: | KeyDefinition + | UserKeyDefinition | [DeriveDefinition, string], options: DeriveDefinitionOptions, ) { - if (isKeyDefinition(definition)) { - return new DeriveDefinition(definition.stateDefinition, definition.key, options); - } else { + if (isFromDeriveDefinition(definition)) { return new DeriveDefinition(definition[0].stateDefinition, definition[1], options); + } else { + return new DeriveDefinition(definition.stateDefinition, definition.key, options); } } static fromWithUserId( definition: | KeyDefinition + | UserKeyDefinition | [DeriveDefinition, string], options: DeriveDefinitionOptions<[UserId, TKeyDef], TTo, TDeps>, ) { - if (isKeyDefinition(definition)) { - return new DeriveDefinition(definition.stateDefinition, definition.key, options); - } else { + if (isFromDeriveDefinition(definition)) { return new DeriveDefinition(definition[0].stateDefinition, definition[1], options); + } else { + return new DeriveDefinition(definition.stateDefinition, definition.key, options); } } @@ -181,10 +184,11 @@ export class DeriveDefinition + | UserKeyDefinition | [DeriveDefinition, string], -): definition is KeyDefinition { - return Object.prototype.hasOwnProperty.call(definition, "key"); +): definition is [DeriveDefinition, string] { + return Array.isArray(definition); } diff --git a/libs/common/src/platform/state/implementations/default-active-user-state.spec.ts b/libs/common/src/platform/state/implementations/default-active-user-state.spec.ts index 6e01b615d7..51a972a9dc 100644 --- a/libs/common/src/platform/state/implementations/default-active-user-state.spec.ts +++ b/libs/common/src/platform/state/implementations/default-active-user-state.spec.ts @@ -9,7 +9,6 @@ import { Jsonify } from "type-fest"; import { awaitAsync, trackEmissions } from "../../../../spec"; import { FakeStorageService } from "../../../../spec/fake-storage.service"; import { AccountInfo } from "../../../auth/abstractions/account.service"; -import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { UserId } from "../../../types/guid"; import { StorageServiceProvider } from "../../services/storage-service.provider"; import { StateDefinition } from "../state-definition"; @@ -84,7 +83,6 @@ describe("DefaultActiveUserState", () => { id: userId, email: `test${id}@example.com`, name: `Test User ${id}`, - status: AuthenticationStatus.Unlocked, }); await awaitAsync(); }; diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 1c4f38335d..c88db37a94 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -37,12 +37,17 @@ export const BILLING_DISK = new StateDefinition("billing", "disk"); export const KEY_CONNECTOR_DISK = new StateDefinition("keyConnector", "disk"); export const ACCOUNT_MEMORY = new StateDefinition("account", "memory"); +export const MASTER_PASSWORD_MEMORY = new StateDefinition("masterPassword", "memory"); +export const MASTER_PASSWORD_DISK = new StateDefinition("masterPassword", "disk"); export const AVATAR_DISK = new StateDefinition("avatar", "disk", { web: "disk-local" }); export const ROUTER_DISK = new StateDefinition("router", "disk"); export const LOGIN_EMAIL_DISK = new StateDefinition("loginEmail", "disk", { web: "disk-local", }); export const LOGIN_STRATEGY_MEMORY = new StateDefinition("loginStrategy", "memory"); +export const AUTH_REQUEST_DISK_LOCAL = new StateDefinition("authRequestLocal", "disk", { + web: "disk-local", +}); export const SSO_DISK = new StateDefinition("ssoLogin", "disk"); export const TOKEN_DISK = new StateDefinition("token", "disk"); export const TOKEN_DISK_LOCAL = new StateDefinition("tokenDiskLocal", "disk", { @@ -74,6 +79,10 @@ export const NEW_WEB_LAYOUT_BANNER_DISK = new StateDefinition("newWebLayoutBanne web: "disk-local", }); +export const UNASSIGNED_ITEMS_BANNER_DISK = new StateDefinition("unassignedItemsBanner", "disk", { + web: "disk-local", +}); + // Platform export const APPLICATION_ID_DISK = new StateDefinition("applicationId", "disk", { @@ -103,6 +112,7 @@ export const SM_ONBOARDING_DISK = new StateDefinition("smOnboarding", "disk", { export const GENERATOR_DISK = new StateDefinition("generator", "disk"); export const GENERATOR_MEMORY = new StateDefinition("generator", "memory"); +export const BROWSER_SEND_MEMORY = new StateDefinition("sendBrowser", "memory"); export const EVENT_COLLECTION_DISK = new StateDefinition("eventCollection", "disk"); export const SEND_DISK = new StateDefinition("encryptedSend", "disk", { web: "memory", @@ -125,3 +135,9 @@ export const VAULT_SETTINGS_DISK = new StateDefinition("vaultSettings", "disk", web: "disk-local", }); export const VAULT_BROWSER_MEMORY = new StateDefinition("vaultBrowser", "memory"); +export const VAULT_SEARCH_MEMORY = new StateDefinition("vaultSearch", "memory"); +export const CIPHERS_DISK = new StateDefinition("ciphers", "disk", { web: "memory" }); +export const CIPHERS_DISK_LOCAL = new StateDefinition("ciphersLocal", "disk", { + web: "disk-local", +}); +export const CIPHERS_MEMORY = new StateDefinition("ciphersMemory", "memory"); diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 6306eb1e28..c7a8f3f091 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -565,8 +565,12 @@ export class ApiService implements ApiServiceAbstraction { return this.send("PUT", "/ciphers/share", request, true, false); } - putCipherCollections(id: string, request: CipherCollectionsRequest): Promise { - return this.send("PUT", "/ciphers/" + id + "/collections", request, true, false); + async putCipherCollections( + id: string, + request: CipherCollectionsRequest, + ): Promise { + const response = await this.send("PUT", "/ciphers/" + id + "/collections", request, true, true); + return new CipherResponse(response); } putCipherCollectionsAdmin(id: string, request: CipherCollectionsRequest): Promise { diff --git a/libs/common/src/services/event/event-collection.service.ts b/libs/common/src/services/event/event-collection.service.ts index 2d2b553062..641c1b4d44 100644 --- a/libs/common/src/services/event/event-collection.service.ts +++ b/libs/common/src/services/event/event-collection.service.ts @@ -3,7 +3,7 @@ import { firstValueFrom, map, from, zip } from "rxjs"; import { EventCollectionService as EventCollectionServiceAbstraction } from "../../abstractions/event/event-collection.service"; import { EventUploadService } from "../../abstractions/event/event-upload.service"; import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction"; -import { AccountService } from "../../auth/abstractions/account.service"; +import { AuthService } from "../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { EventType } from "../../enums"; import { EventData } from "../../models/data/event.data"; @@ -18,7 +18,7 @@ export class EventCollectionService implements EventCollectionServiceAbstraction private stateProvider: StateProvider, private organizationService: OrganizationService, private eventUploadService: EventUploadService, - private accountService: AccountService, + private authService: AuthService, ) {} /** Adds an event to the active user's event collection @@ -71,12 +71,12 @@ export class EventCollectionService implements EventCollectionServiceAbstraction const cipher$ = from(this.cipherService.get(cipherId)); - const [accountInfo, orgIds, cipher] = await firstValueFrom( - zip(this.accountService.activeAccount$, orgIds$, cipher$), + const [authStatus, orgIds, cipher] = await firstValueFrom( + zip(this.authService.activeAccountStatus$, orgIds$, cipher$), ); // The user must be authorized - if (accountInfo.status != AuthenticationStatus.Unlocked) { + if (authStatus != AuthenticationStatus.Unlocked) { return false; } diff --git a/libs/common/src/services/event/event-upload.service.ts b/libs/common/src/services/event/event-upload.service.ts index 0495e593b1..2422c790b7 100644 --- a/libs/common/src/services/event/event-upload.service.ts +++ b/libs/common/src/services/event/event-upload.service.ts @@ -2,7 +2,7 @@ import { firstValueFrom, map } from "rxjs"; import { ApiService } from "../../abstractions/api.service"; import { EventUploadService as EventUploadServiceAbstraction } from "../../abstractions/event/event-upload.service"; -import { AccountService } from "../../auth/abstractions/account.service"; +import { AuthService } from "../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { EventData } from "../../models/data/event.data"; import { EventRequest } from "../../models/request/event.request"; @@ -20,7 +20,7 @@ export class EventUploadService implements EventUploadServiceAbstraction { private apiService: ApiService, private stateProvider: StateProvider, private logService: LogService, - private accountService: AccountService, + private authService: AuthService, private taskSchedulerService: TaskSchedulerService, ) {} @@ -48,13 +48,16 @@ export class EventUploadService implements EventUploadServiceAbstraction { userId = await firstValueFrom(this.stateProvider.activeUserId$); } - // Get the auth status from the provided user or the active user - const userAuth$ = this.accountService.accounts$.pipe( - map((accounts) => accounts[userId]?.status === AuthenticationStatus.Unlocked), - ); + if (!userId) { + return; + } - const isAuthenticated = await firstValueFrom(userAuth$); - if (!isAuthenticated) { + const isUnlocked = await firstValueFrom( + this.authService + .authStatusFor$(userId) + .pipe(map((status) => status === AuthenticationStatus.Unlocked)), + ); + if (!isUnlocked) { return; } diff --git a/libs/common/src/services/notifications.service.ts b/libs/common/src/services/notifications.service.ts index 11eb6afb26..546d7e45d4 100644 --- a/libs/common/src/services/notifications.service.ts +++ b/libs/common/src/services/notifications.service.ts @@ -2,6 +2,7 @@ import * as signalR from "@microsoft/signalr"; import * as signalRMsgPack from "@microsoft/signalr-protocol-msgpack"; import { firstValueFrom } from "rxjs"; +import { AuthRequestServiceAbstraction } from "../../../auth/src/common/abstractions"; import { ApiService } from "../abstractions/api.service"; import { NotificationsService as NotificationsServiceAbstraction } from "../abstractions/notifications.service"; import { AuthService } from "../auth/abstractions/auth.service"; @@ -18,6 +19,7 @@ import { EnvironmentService } from "../platform/abstractions/environment.service import { LogService } from "../platform/abstractions/log.service"; import { MessagingService } from "../platform/abstractions/messaging.service"; import { StateService } from "../platform/abstractions/state.service"; +import { UserId } from "../types/guid"; import { TaskSchedulerService } from "../platform/abstractions/task-scheduler.service"; import { ScheduledTaskNames } from "../platform/enums/scheduled-task-name.enum"; import { SyncService } from "../vault/abstractions/sync/sync.service.abstraction"; @@ -39,6 +41,7 @@ export class NotificationsService implements NotificationsServiceAbstraction { private logoutCallback: (expired: boolean) => Promise, private stateService: StateService, private authService: AuthService, + private authRequestService: AuthRequestServiceAbstraction, private messagingService: MessagingService, private taskSchedulerService: TaskSchedulerService, ) { @@ -202,10 +205,13 @@ export class NotificationsService implements NotificationsServiceAbstraction { await this.syncService.syncDeleteSend(notification.payload as SyncSendNotification); break; case NotificationType.AuthRequest: - if (await this.stateService.getApproveLoginRequests()) { - this.messagingService.send("openLoginApproval", { - notificationId: notification.payload.id, - }); + { + const userId = await this.stateService.getUserId(); + if (await this.authRequestService.getAcceptAuthRequests(userId as UserId)) { + this.messagingService.send("openLoginApproval", { + notificationId: notification.payload.id, + }); + } } break; default: diff --git a/libs/common/src/services/search.service.ts b/libs/common/src/services/search.service.ts index 773d51297a..38ddfe0e47 100644 --- a/libs/common/src/services/search.service.ts +++ b/libs/common/src/services/search.service.ts @@ -1,20 +1,91 @@ import * as lunr from "lunr"; +import { Observable, firstValueFrom, map } from "rxjs"; +import { Jsonify } from "type-fest"; import { SearchService as SearchServiceAbstraction } from "../abstractions/search.service"; import { UriMatchStrategy } from "../models/domain/domain-service"; import { I18nService } from "../platform/abstractions/i18n.service"; import { LogService } from "../platform/abstractions/log.service"; +import { + ActiveUserState, + StateProvider, + UserKeyDefinition, + VAULT_SEARCH_MEMORY, +} from "../platform/state"; import { SendView } from "../tools/send/models/view/send.view"; +import { IndexedEntityId } from "../types/guid"; import { FieldType } from "../vault/enums"; import { CipherType } from "../vault/enums/cipher-type"; import { CipherView } from "../vault/models/view/cipher.view"; +export type SerializedLunrIndex = { + version: string; + fields: string[]; + fieldVectors: [string, number[]]; + invertedIndex: any[]; + pipeline: string[]; +}; + +/** + * The `KeyDefinition` for accessing the search index in application state. + * The key definition is configured to clear the index when the user locks the vault. + */ +export const LUNR_SEARCH_INDEX = new UserKeyDefinition( + VAULT_SEARCH_MEMORY, + "searchIndex", + { + deserializer: (obj: Jsonify) => obj, + clearOn: ["lock", "logout"], + }, +); + +/** + * The `KeyDefinition` for accessing the ID of the entity currently indexed by Lunr search. + * The key definition is configured to clear the indexed entity ID when the user locks the vault. + */ +export const LUNR_SEARCH_INDEXED_ENTITY_ID = new UserKeyDefinition( + VAULT_SEARCH_MEMORY, + "searchIndexedEntityId", + { + deserializer: (obj: Jsonify) => obj, + clearOn: ["lock", "logout"], + }, +); + +/** + * The `KeyDefinition` for accessing the state of Lunr search indexing, indicating whether the Lunr search index is currently being built or updating. + * The key definition is configured to clear the indexing state when the user locks the vault. + */ +export const LUNR_SEARCH_INDEXING = new UserKeyDefinition( + VAULT_SEARCH_MEMORY, + "isIndexing", + { + deserializer: (obj: Jsonify) => obj, + clearOn: ["lock", "logout"], + }, +); + export class SearchService implements SearchServiceAbstraction { private static registeredPipeline = false; - indexedEntityId?: string = null; - private indexing = false; - private index: lunr.Index = null; + private searchIndexState: ActiveUserState = + this.stateProvider.getActive(LUNR_SEARCH_INDEX); + private readonly index$: Observable = this.searchIndexState.state$.pipe( + map((searchIndex) => (searchIndex ? lunr.Index.load(searchIndex) : null)), + ); + + private searchIndexEntityIdState: ActiveUserState = this.stateProvider.getActive( + LUNR_SEARCH_INDEXED_ENTITY_ID, + ); + readonly indexedEntityId$: Observable = + this.searchIndexEntityIdState.state$.pipe(map((id) => id)); + + private searchIsIndexingState: ActiveUserState = + this.stateProvider.getActive(LUNR_SEARCH_INDEXING); + private readonly searchIsIndexing$: Observable = this.searchIsIndexingState.state$.pipe( + map((indexing) => indexing ?? false), + ); + private readonly immediateSearchLocales: string[] = ["zh-CN", "zh-TW", "ja", "ko", "vi"]; private readonly defaultSearchableMinLength: number = 2; private searchableMinLength: number = this.defaultSearchableMinLength; @@ -22,6 +93,7 @@ export class SearchService implements SearchServiceAbstraction { constructor( private logService: LogService, private i18nService: I18nService, + private stateProvider: StateProvider, ) { this.i18nService.locale$.subscribe((locale) => { if (this.immediateSearchLocales.indexOf(locale) !== -1) { @@ -40,28 +112,29 @@ export class SearchService implements SearchServiceAbstraction { } } - clearIndex(): void { - this.indexedEntityId = null; - this.index = null; + async clearIndex(): Promise { + await this.searchIndexEntityIdState.update(() => null); + await this.searchIndexState.update(() => null); + await this.searchIsIndexingState.update(() => null); } - isSearchable(query: string): boolean { + async isSearchable(query: string): Promise { query = SearchService.normalizeSearchQuery(query); + const index = await this.getIndexForSearch(); const notSearchable = query == null || - (this.index == null && query.length < this.searchableMinLength) || - (this.index != null && query.length < this.searchableMinLength && query.indexOf(">") !== 0); + (index == null && query.length < this.searchableMinLength) || + (index != null && query.length < this.searchableMinLength && query.indexOf(">") !== 0); return !notSearchable; } - indexCiphers(ciphers: CipherView[], indexedEntityId?: string): void { - if (this.indexing) { + async indexCiphers(ciphers: CipherView[], indexedEntityId?: string): Promise { + if (await this.getIsIndexing()) { return; } - this.indexing = true; - this.indexedEntityId = indexedEntityId; - this.index = null; + await this.setIsIndexing(true); + await this.setIndexedEntityIdForSearch(indexedEntityId as IndexedEntityId); const builder = new lunr.Builder(); builder.pipeline.add(this.normalizeAccentsPipelineFunction); builder.ref("id"); @@ -95,9 +168,11 @@ export class SearchService implements SearchServiceAbstraction { builder.field("organizationid", { extractor: (c: CipherView) => c.organizationId }); ciphers = ciphers || []; ciphers.forEach((c) => builder.add(c)); - this.index = builder.build(); + const index = builder.build(); - this.indexing = false; + await this.setIndexForSearch(index.toJSON() as SerializedLunrIndex); + + await this.setIsIndexing(false); this.logService.info("Finished search indexing"); } @@ -125,18 +200,18 @@ export class SearchService implements SearchServiceAbstraction { ciphers = ciphers.filter(filter as (cipher: CipherView) => boolean); } - if (!this.isSearchable(query)) { + if (!(await this.isSearchable(query))) { return ciphers; } - if (this.indexing) { + if (await this.getIsIndexing()) { await new Promise((r) => setTimeout(r, 250)); - if (this.indexing) { + if (await this.getIsIndexing()) { await new Promise((r) => setTimeout(r, 500)); } } - const index = this.getIndexForSearch(); + const index = await this.getIndexForSearch(); if (index == null) { // Fall back to basic search if index is not available return this.searchCiphersBasic(ciphers, query); @@ -230,8 +305,24 @@ export class SearchService implements SearchServiceAbstraction { return sendsMatched.concat(lowPriorityMatched); } - getIndexForSearch(): lunr.Index { - return this.index; + async getIndexForSearch(): Promise { + return await firstValueFrom(this.index$); + } + + private async setIndexForSearch(index: SerializedLunrIndex): Promise { + await this.searchIndexState.update(() => index); + } + + private async setIndexedEntityIdForSearch(indexedEntityId: IndexedEntityId): Promise { + await this.searchIndexEntityIdState.update(() => indexedEntityId); + } + + private async setIsIndexing(indexing: boolean): Promise { + await this.searchIsIndexingState.update(() => indexing); + } + + private async getIsIndexing(): Promise { + return await firstValueFrom(this.searchIsIndexing$); } private fieldExtractor(c: CipherView, joined: boolean) { diff --git a/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts b/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts index e48f2fe0a3..243b644dd8 100644 --- a/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts +++ b/libs/common/src/services/vault-timeout/vault-timeout.service.spec.ts @@ -1,17 +1,21 @@ import { MockProxy, any, mock } from "jest-mock-extended"; import { BehaviorSubject } from "rxjs"; +import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service"; import { SearchService } from "../../abstractions/search.service"; import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service"; import { AuthService } from "../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../auth/enums/authentication-status"; +import { FakeMasterPasswordService } from "../../auth/services/master-password/fake-master-password.service"; import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; import { CryptoService } from "../../platform/abstractions/crypto.service"; import { MessagingService } from "../../platform/abstractions/messaging.service"; import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; import { StateService } from "../../platform/abstractions/state.service"; +import { Utils } from "../../platform/misc/utils"; import { Account } from "../../platform/models/domain/account"; import { StateEventRunnerService } from "../../platform/state"; +import { UserId } from "../../types/guid"; import { CipherService } from "../../vault/abstractions/cipher.service"; import { CollectionService } from "../../vault/abstractions/collection.service"; import { FolderService } from "../../vault/abstractions/folder/folder.service.abstraction"; @@ -19,6 +23,8 @@ import { FolderService } from "../../vault/abstractions/folder/folder.service.ab import { VaultTimeoutService } from "./vault-timeout.service"; describe("VaultTimeoutService", () => { + let accountService: FakeAccountService; + let masterPasswordService: FakeMasterPasswordService; let cipherService: MockProxy; let folderService: MockProxy; let collectionService: MockProxy; @@ -39,7 +45,11 @@ describe("VaultTimeoutService", () => { let vaultTimeoutService: VaultTimeoutService; + const userId = Utils.newGuid() as UserId; + beforeEach(() => { + accountService = mockAccountServiceWith(userId); + masterPasswordService = new FakeMasterPasswordService(); cipherService = mock(); folderService = mock(); collectionService = mock(); @@ -66,6 +76,8 @@ describe("VaultTimeoutService", () => { availableVaultTimeoutActionsSubject = new BehaviorSubject([]); vaultTimeoutService = new VaultTimeoutService( + accountService, + masterPasswordService, cipherService, folderService, collectionService, @@ -123,6 +135,14 @@ describe("VaultTimeoutService", () => { stateService.activeAccount$ = new BehaviorSubject(globalSetups?.userId); + if (globalSetups?.userId) { + accountService.activeAccountSubject.next({ + id: globalSetups.userId as UserId, + email: null, + name: null, + }); + } + platformUtilsService.isViewOpen.mockResolvedValue(globalSetups?.isViewOpen ?? false); vaultTimeoutSettingsService.vaultTimeoutAction$.mockImplementation((userId) => { @@ -156,8 +176,7 @@ describe("VaultTimeoutService", () => { expect(vaultTimeoutSettingsService.availableVaultTimeoutActions$).toHaveBeenCalledWith(userId); expect(stateService.setEverBeenUnlocked).toHaveBeenCalledWith(true, { userId: userId }); expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(null, { userId: userId }); - expect(cryptoService.clearUserKey).toHaveBeenCalledWith(false, userId); - expect(cryptoService.clearMasterKey).toHaveBeenCalledWith(userId); + expect(masterPasswordService.mock.clearMasterKey).toHaveBeenCalledWith(userId); expect(cipherService.clearCache).toHaveBeenCalledWith(userId); expect(lockedCallback).toHaveBeenCalledWith(userId); }; diff --git a/libs/common/src/services/vault-timeout/vault-timeout.service.ts b/libs/common/src/services/vault-timeout/vault-timeout.service.ts index c3270ac2b8..35faf0fcee 100644 --- a/libs/common/src/services/vault-timeout/vault-timeout.service.ts +++ b/libs/common/src/services/vault-timeout/vault-timeout.service.ts @@ -3,7 +3,9 @@ import { firstValueFrom, timeout } from "rxjs"; import { SearchService } from "../../abstractions/search.service"; import { VaultTimeoutSettingsService } from "../../abstractions/vault-timeout/vault-timeout-settings.service"; import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "../../abstractions/vault-timeout/vault-timeout.service"; +import { AccountService } from "../../auth/abstractions/account.service"; import { AuthService } from "../../auth/abstractions/auth.service"; +import { InternalMasterPasswordServiceAbstraction } from "../../auth/abstractions/master-password.service.abstraction"; import { AuthenticationStatus } from "../../auth/enums/authentication-status"; import { ClientType } from "../../enums"; import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum"; @@ -21,6 +23,8 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { private inited = false; constructor( + private accountService: AccountService, + private masterPasswordService: InternalMasterPasswordServiceAbstraction, private cipherService: CipherService, private folderService: FolderService, private collectionService: CollectionService, @@ -84,23 +88,20 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { await this.logOut(userId); } - const currentUserId = await this.stateService.getUserId(); + const currentUserId = (await firstValueFrom(this.accountService.activeAccount$)).id; if (userId == null || userId === currentUserId) { - this.searchService.clearIndex(); + await this.searchService.clearIndex(); await this.folderService.clearCache(); await this.collectionService.clearActiveUserCache(); } + await this.masterPasswordService.clearMasterKey((userId ?? currentUserId) as UserId); + await this.stateService.setEverBeenUnlocked(true, { userId: userId }); await this.stateService.setUserKeyAutoUnlock(null, { userId: userId }); await this.stateService.setCryptoMasterKeyAuto(null, { userId: userId }); - await this.cryptoService.clearUserKey(false, userId); - await this.cryptoService.clearMasterKey(userId); - await this.cryptoService.clearOrgKeys(true, userId); - await this.cryptoService.clearKeyPair(true, userId); - await this.cipherService.clearCache(userId); await this.stateEventRunnerService.handleEvent("lock", (userId ?? currentUserId) as UserId); diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index faccddb0af..000f85519e 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -51,6 +51,9 @@ import { RememberedEmailMigrator } from "./migrations/51-move-remembered-email-t import { DeleteInstalledVersion } from "./migrations/52-delete-installed-version"; import { DeviceTrustCryptoServiceStateProviderMigrator } from "./migrations/53-migrate-device-trust-crypto-svc-to-state-providers"; import { SendMigrator } from "./migrations/54-move-encrypted-sends"; +import { MoveMasterKeyStateToProviderMigrator } from "./migrations/55-move-master-key-state-to-provider"; +import { AuthRequestMigrator } from "./migrations/56-move-auth-requests"; +import { CipherServiceMigrator } from "./migrations/57-move-cipher-service-to-state-provider"; 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"; @@ -58,8 +61,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 3; -export const CURRENT_VERSION = 54; - +export const CURRENT_VERSION = 57; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -115,7 +117,10 @@ export function createMigrationBuilder() { .with(RememberedEmailMigrator, 50, 51) .with(DeleteInstalledVersion, 51, 52) .with(DeviceTrustCryptoServiceStateProviderMigrator, 52, 53) - .with(SendMigrator, 53, 54); + .with(SendMigrator, 53, 54) + .with(MoveMasterKeyStateToProviderMigrator, 54, 55) + .with(AuthRequestMigrator, 55, 56) + .with(CipherServiceMigrator, 56, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/55-move-master-key-state-to-provider.spec.ts b/libs/common/src/state-migrations/migrations/55-move-master-key-state-to-provider.spec.ts new file mode 100644 index 0000000000..bbf0352e95 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/55-move-master-key-state-to-provider.spec.ts @@ -0,0 +1,210 @@ +import { any, MockProxy } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { + FORCE_SET_PASSWORD_REASON_DEFINITION, + MASTER_KEY_ENCRYPTED_USER_KEY_DEFINITION, + MASTER_KEY_HASH_DEFINITION, + MoveMasterKeyStateToProviderMigrator, +} from "./55-move-master-key-state-to-provider"; + +function preMigrationState() { + return { + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["FirstAccount", "SecondAccount", "ThirdAccount"], + // prettier-ignore + "FirstAccount": { + profile: { + forceSetPasswordReason: "FirstAccount_forceSetPasswordReason", + keyHash: "FirstAccount_keyHash", + otherStuff: "overStuff2", + }, + keys: { + masterKeyEncryptedUserKey: "FirstAccount_masterKeyEncryptedUserKey", + }, + otherStuff: "otherStuff3", + }, + // prettier-ignore + "SecondAccount": { + profile: { + forceSetPasswordReason: "SecondAccount_forceSetPasswordReason", + keyHash: "SecondAccount_keyHash", + otherStuff: "otherStuff4", + }, + keys: { + masterKeyEncryptedUserKey: "SecondAccount_masterKeyEncryptedUserKey", + }, + otherStuff: "otherStuff5", + }, + // prettier-ignore + "ThirdAccount": { + profile: { + otherStuff: "otherStuff6", + }, + }, + }; +} + +function postMigrationState() { + return { + user_FirstAccount_masterPassword_forceSetPasswordReason: "FirstAccount_forceSetPasswordReason", + user_FirstAccount_masterPassword_masterKeyHash: "FirstAccount_keyHash", + user_FirstAccount_masterPassword_masterKeyEncryptedUserKey: + "FirstAccount_masterKeyEncryptedUserKey", + user_SecondAccount_masterPassword_forceSetPasswordReason: + "SecondAccount_forceSetPasswordReason", + user_SecondAccount_masterPassword_masterKeyHash: "SecondAccount_keyHash", + user_SecondAccount_masterPassword_masterKeyEncryptedUserKey: + "SecondAccount_masterKeyEncryptedUserKey", + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["FirstAccount", "SecondAccount"], + // prettier-ignore + "FirstAccount": { + profile: { + otherStuff: "overStuff2", + }, + otherStuff: "otherStuff3", + }, + // prettier-ignore + "SecondAccount": { + profile: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + // prettier-ignore + "ThirdAccount": { + profile: { + otherStuff: "otherStuff6", + }, + }, + }; +} + +describe("MoveForceSetPasswordReasonToStateProviderMigrator", () => { + let helper: MockProxy; + let sut: MoveMasterKeyStateToProviderMigrator; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(preMigrationState(), 54); + sut = new MoveMasterKeyStateToProviderMigrator(54, 55); + }); + + it("should remove properties from existing accounts", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("FirstAccount", { + profile: { + otherStuff: "overStuff2", + }, + keys: {}, + otherStuff: "otherStuff3", + }); + expect(helper.set).toHaveBeenCalledWith("SecondAccount", { + profile: { + otherStuff: "otherStuff4", + }, + keys: {}, + otherStuff: "otherStuff5", + }); + }); + + it("should set properties for each account", async () => { + await sut.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith( + "FirstAccount", + FORCE_SET_PASSWORD_REASON_DEFINITION, + "FirstAccount_forceSetPasswordReason", + ); + + expect(helper.setToUser).toHaveBeenCalledWith( + "FirstAccount", + MASTER_KEY_HASH_DEFINITION, + "FirstAccount_keyHash", + ); + + expect(helper.setToUser).toHaveBeenCalledWith( + "FirstAccount", + MASTER_KEY_ENCRYPTED_USER_KEY_DEFINITION, + "FirstAccount_masterKeyEncryptedUserKey", + ); + + expect(helper.setToUser).toHaveBeenCalledWith( + "SecondAccount", + FORCE_SET_PASSWORD_REASON_DEFINITION, + "SecondAccount_forceSetPasswordReason", + ); + + expect(helper.setToUser).toHaveBeenCalledWith( + "SecondAccount", + MASTER_KEY_HASH_DEFINITION, + "SecondAccount_keyHash", + ); + + expect(helper.setToUser).toHaveBeenCalledWith( + "SecondAccount", + MASTER_KEY_ENCRYPTED_USER_KEY_DEFINITION, + "SecondAccount_masterKeyEncryptedUserKey", + ); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(postMigrationState(), 55); + sut = new MoveMasterKeyStateToProviderMigrator(54, 55); + }); + + it.each(["FirstAccount", "SecondAccount"])("should null out new values", async (userId) => { + await sut.rollback(helper); + + expect(helper.setToUser).toHaveBeenCalledWith( + userId, + FORCE_SET_PASSWORD_REASON_DEFINITION, + null, + ); + + expect(helper.setToUser).toHaveBeenCalledWith(userId, MASTER_KEY_HASH_DEFINITION, null); + }); + + it("should add explicit value back to accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledWith("FirstAccount", { + profile: { + forceSetPasswordReason: "FirstAccount_forceSetPasswordReason", + keyHash: "FirstAccount_keyHash", + otherStuff: "overStuff2", + }, + keys: { + masterKeyEncryptedUserKey: "FirstAccount_masterKeyEncryptedUserKey", + }, + otherStuff: "otherStuff3", + }); + expect(helper.set).toHaveBeenCalledWith("SecondAccount", { + profile: { + forceSetPasswordReason: "SecondAccount_forceSetPasswordReason", + keyHash: "SecondAccount_keyHash", + otherStuff: "otherStuff4", + }, + keys: { + masterKeyEncryptedUserKey: "SecondAccount_masterKeyEncryptedUserKey", + }, + otherStuff: "otherStuff5", + }); + }); + + it("should not try to restore values to missing accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).not.toHaveBeenCalledWith("ThirdAccount", any()); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/55-move-master-key-state-to-provider.ts b/libs/common/src/state-migrations/migrations/55-move-master-key-state-to-provider.ts new file mode 100644 index 0000000000..99b22b5661 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/55-move-master-key-state-to-provider.ts @@ -0,0 +1,111 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +type ExpectedAccountType = { + keys?: { + masterKeyEncryptedUserKey?: string; + }; + profile?: { + forceSetPasswordReason?: number; + keyHash?: string; + }; +}; + +export const FORCE_SET_PASSWORD_REASON_DEFINITION: KeyDefinitionLike = { + key: "forceSetPasswordReason", + stateDefinition: { + name: "masterPassword", + }, +}; + +export const MASTER_KEY_HASH_DEFINITION: KeyDefinitionLike = { + key: "masterKeyHash", + stateDefinition: { + name: "masterPassword", + }, +}; + +export const MASTER_KEY_ENCRYPTED_USER_KEY_DEFINITION: KeyDefinitionLike = { + key: "masterKeyEncryptedUserKey", + stateDefinition: { + name: "masterPassword", + }, +}; + +export class MoveMasterKeyStateToProviderMigrator extends Migrator<54, 55> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + async function migrateAccount(userId: string, account: ExpectedAccountType): Promise { + const forceSetPasswordReason = account?.profile?.forceSetPasswordReason; + if (forceSetPasswordReason != null) { + await helper.setToUser( + userId, + FORCE_SET_PASSWORD_REASON_DEFINITION, + forceSetPasswordReason, + ); + + delete account.profile.forceSetPasswordReason; + await helper.set(userId, account); + } + + const masterKeyHash = account?.profile?.keyHash; + if (masterKeyHash != null) { + await helper.setToUser(userId, MASTER_KEY_HASH_DEFINITION, masterKeyHash); + + delete account.profile.keyHash; + await helper.set(userId, account); + } + + const masterKeyEncryptedUserKey = account?.keys?.masterKeyEncryptedUserKey; + if (masterKeyEncryptedUserKey != null) { + await helper.setToUser( + userId, + MASTER_KEY_ENCRYPTED_USER_KEY_DEFINITION, + masterKeyEncryptedUserKey, + ); + + delete account.keys.masterKeyEncryptedUserKey; + 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 { + const forceSetPasswordReason = await helper.getFromUser( + userId, + FORCE_SET_PASSWORD_REASON_DEFINITION, + ); + const masterKeyHash = await helper.getFromUser(userId, MASTER_KEY_HASH_DEFINITION); + const masterKeyEncryptedUserKey = await helper.getFromUser( + userId, + MASTER_KEY_ENCRYPTED_USER_KEY_DEFINITION, + ); + if (account != null) { + if (forceSetPasswordReason != null) { + account.profile = Object.assign(account.profile ?? {}, { + forceSetPasswordReason, + }); + } + if (masterKeyHash != null) { + account.profile = Object.assign(account.profile ?? {}, { + keyHash: masterKeyHash, + }); + } + if (masterKeyEncryptedUserKey != null) { + account.keys = Object.assign(account.keys ?? {}, { + masterKeyEncryptedUserKey, + }); + } + await helper.set(userId, account); + } + + await helper.setToUser(userId, FORCE_SET_PASSWORD_REASON_DEFINITION, null); + await helper.setToUser(userId, MASTER_KEY_HASH_DEFINITION, null); + } + + await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]); + } +} diff --git a/libs/common/src/state-migrations/migrations/56-move-auth-requests.spec.ts b/libs/common/src/state-migrations/migrations/56-move-auth-requests.spec.ts new file mode 100644 index 0000000000..f6bddbce7d --- /dev/null +++ b/libs/common/src/state-migrations/migrations/56-move-auth-requests.spec.ts @@ -0,0 +1,138 @@ +import { MockProxy } from "jest-mock-extended"; + +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { AuthRequestMigrator } from "./56-move-auth-requests"; + +function exampleJSON() { + return { + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["FirstAccount", "SecondAccount"], + FirstAccount: { + settings: { + otherStuff: "otherStuff2", + approveLoginRequests: true, + }, + otherStuff: "otherStuff3", + adminAuthRequest: { + id: "id1", + privateKey: "privateKey1", + }, + }, + SecondAccount: { + settings: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +function rollbackJSON() { + return { + user_FirstAccount_authRequestLocal_adminAuthRequest: { + id: "id1", + privateKey: "privateKey1", + }, + user_FirstAccount_authRequestLocal_acceptAuthRequests: true, + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["FirstAccount", "SecondAccount"], + FirstAccount: { + settings: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }, + SecondAccount: { + settings: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }; +} + +const ADMIN_AUTH_REQUEST_KEY: KeyDefinitionLike = { + stateDefinition: { + name: "authRequestLocal", + }, + key: "adminAuthRequest", +}; + +const ACCEPT_AUTH_REQUESTS_KEY: KeyDefinitionLike = { + stateDefinition: { + name: "authRequestLocal", + }, + key: "acceptAuthRequests", +}; + +describe("AuthRequestMigrator", () => { + let helper: MockProxy; + let sut: AuthRequestMigrator; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON(), 55); + sut = new AuthRequestMigrator(55, 56); + }); + + it("removes the existing adminAuthRequest and approveLoginRequests", async () => { + await sut.migrate(helper); + + expect(helper.set).toHaveBeenCalledWith("FirstAccount", { + settings: { + otherStuff: "otherStuff2", + }, + otherStuff: "otherStuff3", + }); + expect(helper.set).not.toHaveBeenCalledWith("SecondAccount"); + }); + + it("sets the adminAuthRequest and approveLoginRequests under the new key definitions", async () => { + await sut.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("FirstAccount", ADMIN_AUTH_REQUEST_KEY, { + id: "id1", + privateKey: "privateKey1", + }); + + expect(helper.setToUser).toHaveBeenCalledWith("FirstAccount", ACCEPT_AUTH_REQUESTS_KEY, true); + expect(helper.setToUser).not.toHaveBeenCalledWith("SecondAccount"); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 56); + sut = new AuthRequestMigrator(55, 56); + }); + + it("nulls the new adminAuthRequest and acceptAuthRequests values", async () => { + await sut.rollback(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("FirstAccount", ADMIN_AUTH_REQUEST_KEY, null); + expect(helper.setToUser).toHaveBeenCalledWith("FirstAccount", ACCEPT_AUTH_REQUESTS_KEY, null); + }); + + it("sets back the adminAuthRequest and approveLoginRequests under old account object", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledWith("FirstAccount", { + adminAuthRequest: { + id: "id1", + privateKey: "privateKey1", + }, + settings: { + otherStuff: "otherStuff2", + approveLoginRequests: true, + }, + otherStuff: "otherStuff3", + }); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/56-move-auth-requests.ts b/libs/common/src/state-migrations/migrations/56-move-auth-requests.ts new file mode 100644 index 0000000000..4fec3b2de0 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/56-move-auth-requests.ts @@ -0,0 +1,104 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +type AdminAuthRequestStorable = { + id: string; + privateKey: string; +}; + +type ExpectedAccountType = { + adminAuthRequest?: AdminAuthRequestStorable; + settings?: { + approveLoginRequests?: boolean; + }; +}; + +const ADMIN_AUTH_REQUEST_KEY: KeyDefinitionLike = { + stateDefinition: { + name: "authRequestLocal", + }, + key: "adminAuthRequest", +}; + +const ACCEPT_AUTH_REQUESTS_KEY: KeyDefinitionLike = { + stateDefinition: { + name: "authRequestLocal", + }, + key: "acceptAuthRequests", +}; + +export class AuthRequestMigrator extends Migrator<55, 56> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + + async function migrateAccount(userId: string, account: ExpectedAccountType): Promise { + let updatedAccount = false; + + // Migrate admin auth request + const existingAdminAuthRequest = account?.adminAuthRequest; + + if (existingAdminAuthRequest != null) { + await helper.setToUser(userId, ADMIN_AUTH_REQUEST_KEY, existingAdminAuthRequest); + delete account.adminAuthRequest; + updatedAccount = true; + } + + // Migrate approve login requests + const existingApproveLoginRequests = account?.settings?.approveLoginRequests; + + if (existingApproveLoginRequests != null) { + await helper.setToUser(userId, ACCEPT_AUTH_REQUESTS_KEY, existingApproveLoginRequests); + delete account.settings.approveLoginRequests; + 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 { + let updatedAccount = false; + // Rollback admin auth request + const migratedAdminAuthRequest: AdminAuthRequestStorable = await helper.getFromUser( + userId, + ADMIN_AUTH_REQUEST_KEY, + ); + + if (migratedAdminAuthRequest != null) { + account.adminAuthRequest = migratedAdminAuthRequest; + updatedAccount = true; + } + + await helper.setToUser(userId, ADMIN_AUTH_REQUEST_KEY, null); + + // Rollback approve login requests + const migratedAcceptAuthRequest: boolean = await helper.getFromUser( + userId, + ACCEPT_AUTH_REQUESTS_KEY, + ); + + if (migratedAcceptAuthRequest != null) { + account.settings = Object.assign(account.settings ?? {}, { + approveLoginRequests: migratedAcceptAuthRequest, + }); + updatedAccount = true; + } + + await helper.setToUser(userId, ACCEPT_AUTH_REQUESTS_KEY, null); + + if (updatedAccount) { + await helper.set(userId, account); + } + } + + await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]); + } +} diff --git a/libs/common/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.spec.ts b/libs/common/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.spec.ts new file mode 100644 index 0000000000..499cff1c89 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.spec.ts @@ -0,0 +1,170 @@ +import { MockProxy, any } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { + CIPHERS_DISK, + CIPHERS_DISK_LOCAL, + CipherServiceMigrator, +} from "./57-move-cipher-service-to-state-provider"; + +function exampleJSON() { + return { + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user1", "user2"], + user1: { + data: { + localData: { + "6865ba55-7966-4d63-b743-b12000d49631": { + lastUsedDate: 1708950970632, + }, + "f895f099-6739-4cca-9d61-b12200d04bfa": { + lastUsedDate: 1709031916943, + }, + }, + ciphers: { + "cipher-id-10": { + id: "cipher-id-10", + }, + "cipher-id-11": { + id: "cipher-id-11", + }, + }, + }, + }, + user2: { + data: { + otherStuff: "otherStuff5", + }, + }, + }; +} + +function rollbackJSON() { + return { + user_user1_ciphersLocal_localData: { + "6865ba55-7966-4d63-b743-b12000d49631": { + lastUsedDate: 1708950970632, + }, + "f895f099-6739-4cca-9d61-b12200d04bfa": { + lastUsedDate: 1709031916943, + }, + }, + user_user1_ciphers_ciphers: { + "cipher-id-10": { + id: "cipher-id-10", + }, + "cipher-id-11": { + id: "cipher-id-11", + }, + }, + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user1", "user2"], + user1: { + data: {}, + }, + user2: { + data: { + localData: { + otherStuff: "otherStuff3", + }, + ciphers: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }, + }; +} + +describe("CipherServiceMigrator", () => { + let helper: MockProxy; + let sut: CipherServiceMigrator; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON(), 56); + sut = new CipherServiceMigrator(56, 57); + }); + + it("should remove local data and ciphers from all accounts", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("user1", { + data: {}, + }); + }); + + it("should migrate localData and ciphers to state provider for accounts that have the data", async () => { + await sut.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("user1", CIPHERS_DISK_LOCAL, { + "6865ba55-7966-4d63-b743-b12000d49631": { + lastUsedDate: 1708950970632, + }, + "f895f099-6739-4cca-9d61-b12200d04bfa": { + lastUsedDate: 1709031916943, + }, + }); + expect(helper.setToUser).toHaveBeenCalledWith("user1", CIPHERS_DISK, { + "cipher-id-10": { + id: "cipher-id-10", + }, + "cipher-id-11": { + id: "cipher-id-11", + }, + }); + + expect(helper.setToUser).not.toHaveBeenCalledWith("user2", CIPHERS_DISK_LOCAL, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith("user2", CIPHERS_DISK, any()); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 57); + sut = new CipherServiceMigrator(56, 57); + }); + + it.each(["user1", "user2"])("should null out new values", async (userId) => { + await sut.rollback(helper); + expect(helper.setToUser).toHaveBeenCalledWith(userId, CIPHERS_DISK_LOCAL, null); + expect(helper.setToUser).toHaveBeenCalledWith(userId, CIPHERS_DISK, null); + }); + + it("should add back localData and ciphers to all accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledWith("user1", { + data: { + localData: { + "6865ba55-7966-4d63-b743-b12000d49631": { + lastUsedDate: 1708950970632, + }, + "f895f099-6739-4cca-9d61-b12200d04bfa": { + lastUsedDate: 1709031916943, + }, + }, + ciphers: { + "cipher-id-10": { + id: "cipher-id-10", + }, + "cipher-id-11": { + id: "cipher-id-11", + }, + }, + }, + }); + }); + + it("should not add data back if data wasn't migrated or acct doesn't exist", async () => { + await sut.rollback(helper); + + expect(helper.set).not.toHaveBeenCalledWith("user2", any()); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.ts b/libs/common/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.ts new file mode 100644 index 0000000000..e71d889bb7 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.ts @@ -0,0 +1,79 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +type ExpectedAccountType = { + data: { + localData?: unknown; + ciphers?: unknown; + }; +}; + +export const CIPHERS_DISK_LOCAL: KeyDefinitionLike = { + key: "localData", + stateDefinition: { + name: "ciphersLocal", + }, +}; + +export const CIPHERS_DISK: KeyDefinitionLike = { + key: "ciphers", + stateDefinition: { + name: "ciphers", + }, +}; + +export class CipherServiceMigrator extends Migrator<56, 57> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + async function migrateAccount(userId: string, account: ExpectedAccountType): Promise { + let updatedAccount = false; + + //Migrate localData + const localData = account?.data?.localData; + if (localData != null) { + await helper.setToUser(userId, CIPHERS_DISK_LOCAL, localData); + delete account.data.localData; + updatedAccount = true; + } + + //Migrate ciphers + const ciphers = account?.data?.ciphers; + if (ciphers != null) { + await helper.setToUser(userId, CIPHERS_DISK, ciphers); + delete account.data.ciphers; + updatedAccount = true; + } + + if (updatedAccount) { + 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 localData + const localData = await helper.getFromUser(userId, CIPHERS_DISK_LOCAL); + + if (account.data && localData != null) { + account.data.localData = localData; + await helper.set(userId, account); + } + await helper.setToUser(userId, CIPHERS_DISK_LOCAL, null); + + //rollback ciphers + const ciphers = await helper.getFromUser(userId, CIPHERS_DISK); + + if (account.data && ciphers != null) { + account.data.ciphers = ciphers; + await helper.set(userId, account); + } + await helper.setToUser(userId, CIPHERS_DISK, null); + } + + await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]); + } +} diff --git a/libs/common/src/tools/generator/state/buffered-key-definition.spec.ts b/libs/common/src/tools/generator/state/buffered-key-definition.spec.ts new file mode 100644 index 0000000000..b056cba397 --- /dev/null +++ b/libs/common/src/tools/generator/state/buffered-key-definition.spec.ts @@ -0,0 +1,119 @@ +import { GENERATOR_DISK, UserKeyDefinition } from "../../../platform/state"; + +import { BufferedKeyDefinition } from "./buffered-key-definition"; + +describe("BufferedKeyDefinition", () => { + const deserializer = (jsonValue: number) => jsonValue + 1; + + describe("toKeyDefinition", () => { + it("should create a key definition", () => { + const key = new BufferedKeyDefinition(GENERATOR_DISK, "test", { + deserializer, + cleanupDelayMs: 5, + clearOn: [], + }); + + const result = key.toKeyDefinition(); + + expect(result).toBeInstanceOf(UserKeyDefinition); + expect(result.stateDefinition).toBe(GENERATOR_DISK); + expect(result.key).toBe("test"); + expect(result.deserializer(1)).toEqual(2); + expect(result.cleanupDelayMs).toEqual(5); + }); + }); + + describe("shouldOverwrite", () => { + it("should call the shouldOverwrite function when its defined", async () => { + const shouldOverwrite = jest.fn(() => true); + const key = new BufferedKeyDefinition(GENERATOR_DISK, "test", { + deserializer, + shouldOverwrite, + clearOn: [], + }); + + const result = await key.shouldOverwrite(true); + + expect(shouldOverwrite).toHaveBeenCalledWith(true); + expect(result).toStrictEqual(true); + }); + + it("should return true when shouldOverwrite is not defined and the input is truthy", async () => { + const key = new BufferedKeyDefinition(GENERATOR_DISK, "test", { + deserializer, + clearOn: [], + }); + + const result = await key.shouldOverwrite(1); + + expect(result).toStrictEqual(true); + }); + + it("should return false when shouldOverwrite is not defined and the input is falsy", async () => { + const key = new BufferedKeyDefinition(GENERATOR_DISK, "test", { + deserializer, + clearOn: [], + }); + + const result = await key.shouldOverwrite(0); + + expect(result).toStrictEqual(false); + }); + }); + + describe("map", () => { + it("should call the map function when its defined", async () => { + const map = jest.fn((value: number) => Promise.resolve(`${value}`)); + const key = new BufferedKeyDefinition(GENERATOR_DISK, "test", { + deserializer, + map, + clearOn: [], + }); + + const result = await key.map(1, true); + + expect(map).toHaveBeenCalledWith(1, true); + expect(result).toStrictEqual("1"); + }); + + it("should fall back to an identity function when map is not defined", async () => { + const key = new BufferedKeyDefinition(GENERATOR_DISK, "test", { deserializer, clearOn: [] }); + + const result = await key.map(1, null); + + expect(result).toStrictEqual(1); + }); + }); + + describe("isValid", () => { + it("should call the isValid function when its defined", async () => { + const isValid = jest.fn(() => Promise.resolve(true)); + const key = new BufferedKeyDefinition(GENERATOR_DISK, "test", { + deserializer, + isValid, + clearOn: [], + }); + + const result = await key.isValid(1, true); + + expect(isValid).toHaveBeenCalledWith(1, true); + expect(result).toStrictEqual(true); + }); + + it("should return true when isValid is not defined and the input is truthy", async () => { + const key = new BufferedKeyDefinition(GENERATOR_DISK, "test", { deserializer, clearOn: [] }); + + const result = await key.isValid(1, null); + + expect(result).toStrictEqual(true); + }); + + it("should return false when isValid is not defined and the input is falsy", async () => { + const key = new BufferedKeyDefinition(GENERATOR_DISK, "test", { deserializer, clearOn: [] }); + + const result = await key.isValid(0, null); + + expect(result).toStrictEqual(false); + }); + }); +}); diff --git a/libs/common/src/tools/generator/state/buffered-key-definition.ts b/libs/common/src/tools/generator/state/buffered-key-definition.ts new file mode 100644 index 0000000000..5457410f80 --- /dev/null +++ b/libs/common/src/tools/generator/state/buffered-key-definition.ts @@ -0,0 +1,100 @@ +import { UserKeyDefinition, UserKeyDefinitionOptions } from "../../../platform/state"; +// eslint-disable-next-line -- `StateDefinition` used as an argument +import { StateDefinition } from "../../../platform/state/state-definition"; + +/** A set of options for customizing the behavior of a {@link BufferedKeyDefinition} + */ +export type BufferedKeyDefinitionOptions = + UserKeyDefinitionOptions & { + /** Checks whether the input type can be converted to the output type. + * @param input the data that is rolling over. + * @returns `true` if the definition is valid, otherwise `false`. If this + * function is not specified, any truthy input is valid. + * + * @remarks this is intended for cases where you're working with validated or + * signed data. It should be used to prevent data from being "laundered" through + * synchronized state. + */ + isValid?: (input: Input, dependency: Dependency) => Promise; + + /** Transforms the input data format to its output format. + * @param input the data that is rolling over. + * @returns the converted value. If this function is not specified, the value + * is asserted as the output type. + * + * @remarks This is intended for converting between, say, a replication format + * and a disk format or rotating encryption keys. + */ + map?: (input: Input, dependency: Dependency) => Promise; + + /** Checks whether an overwrite should occur + * @param dependency the latest value from the dependency observable provided + * to the buffered state. + * @returns `true` if a overwrite should occur, otherwise `false`. If this + * function is not specified, overwrites occur when the dependency is truthy. + * + * @remarks This is intended for waiting to overwrite until a dependency becomes + * available (e.g. an encryption key or a user confirmation). + */ + shouldOverwrite?: (dependency: Dependency) => boolean; + }; + +/** Storage and mapping settings for data stored by a `BufferedState`. + */ +export class BufferedKeyDefinition { + /** + * Defines a buffered state + * @param stateDefinition The domain of the buffer + * @param key Domain key that identifies the buffered value. This key must + * not be reused in any capacity. + * @param options Configures the operation of the buffered state. + */ + constructor( + readonly stateDefinition: StateDefinition, + readonly key: string, + readonly options: BufferedKeyDefinitionOptions, + ) {} + + /** Converts the buffered key definition to a state provider + * key definition + */ + toKeyDefinition() { + const bufferedKey = new UserKeyDefinition(this.stateDefinition, this.key, this.options); + + return bufferedKey; + } + + /** Checks whether the dependency triggers an overwrite. */ + shouldOverwrite(dependency: Dependency) { + const shouldOverwrite = this.options?.shouldOverwrite; + if (shouldOverwrite) { + return shouldOverwrite(dependency); + } + + return dependency ? true : false; + } + + /** Converts the input data format to its output format. + * @returns the converted value. + */ + map(input: Input, dependency: Dependency) { + const map = this.options?.map; + if (map) { + return map(input, dependency); + } + + return Promise.resolve(input as unknown as Output); + } + + /** Checks whether the input type can be converted to the output type. + * @returns `true` if the definition is valid, otherwise `false`. + */ + isValid(input: Input, dependency: Dependency) { + const isValid = this.options?.isValid; + if (isValid) { + return isValid(input, dependency); + } + + return Promise.resolve(input ? true : false); + } +} diff --git a/libs/common/src/tools/generator/state/buffered-state.spec.ts b/libs/common/src/tools/generator/state/buffered-state.spec.ts new file mode 100644 index 0000000000..7f9722d384 --- /dev/null +++ b/libs/common/src/tools/generator/state/buffered-state.spec.ts @@ -0,0 +1,375 @@ +import { BehaviorSubject, firstValueFrom, of } from "rxjs"; + +import { + mockAccountServiceWith, + FakeStateProvider, + awaitAsync, + trackEmissions, +} from "../../../../spec"; +import { GENERATOR_DISK, KeyDefinition } from "../../../platform/state"; +import { UserId } from "../../../types/guid"; + +import { BufferedKeyDefinition } from "./buffered-key-definition"; +import { BufferedState } from "./buffered-state"; + +const SomeUser = "SomeUser" as UserId; +const accountService = mockAccountServiceWith(SomeUser); +type SomeType = { foo: boolean; bar: boolean }; + +const SOME_KEY = new KeyDefinition(GENERATOR_DISK, "fooBar", { + deserializer: (jsonValue) => jsonValue as SomeType, +}); +const BUFFER_KEY = new BufferedKeyDefinition(GENERATOR_DISK, "fooBar_buffer", { + deserializer: (jsonValue) => jsonValue as SomeType, + clearOn: [], +}); + +describe("BufferedState", () => { + describe("state$", function () { + it("reads from the output state", async () => { + const provider = new FakeStateProvider(accountService); + const value = { foo: true, bar: false }; + const outputState = provider.getUser(SomeUser, SOME_KEY); + await outputState.update(() => value); + const bufferedState = new BufferedState(provider, BUFFER_KEY, outputState); + + const result = await firstValueFrom(bufferedState.state$); + + expect(result).toEqual(value); + }); + + it("updates when the output state updates", async () => { + const provider = new FakeStateProvider(accountService); + const outputState = provider.getUser(SomeUser, SOME_KEY); + const firstValue = { foo: true, bar: false }; + const secondValue = { foo: true, bar: true }; + await outputState.update(() => firstValue); + const bufferedState = new BufferedState(provider, BUFFER_KEY, outputState); + + const result = trackEmissions(bufferedState.state$); + await outputState.update(() => secondValue); + await awaitAsync(); + + expect(result).toEqual([firstValue, secondValue]); + }); + + // this test is important for data migrations, which set + // the buffered state without using the `BufferedState` abstraction. + it.each([[null], [undefined]])( + "reads from the output state when the buffered state is '%p'", + async (bufferValue) => { + const provider = new FakeStateProvider(accountService); + const outputState = provider.getUser(SomeUser, SOME_KEY); + const firstValue = { foo: true, bar: false }; + await outputState.update(() => firstValue); + const bufferedState = new BufferedState(provider, BUFFER_KEY, outputState); + await provider.setUserState(BUFFER_KEY.toKeyDefinition(), bufferValue, SomeUser); + + const result = await firstValueFrom(bufferedState.state$); + + expect(result).toEqual(firstValue); + }, + ); + + // also important for data migrations + it("rolls over pending values from the buffered state immediately by default", async () => { + const provider = new FakeStateProvider(accountService); + const outputState = provider.getUser(SomeUser, SOME_KEY); + await outputState.update(() => ({ foo: true, bar: false })); + const bufferedState = new BufferedState(provider, BUFFER_KEY, outputState); + const bufferedValue = { foo: true, bar: true }; + await provider.setUserState(BUFFER_KEY.toKeyDefinition(), bufferedValue, SomeUser); + + const result = await firstValueFrom(bufferedState.state$); + + expect(result).toEqual(bufferedValue); + }); + + // also important for data migrations + it("reads from the output state when its dependency is false", async () => { + const provider = new FakeStateProvider(accountService); + const outputState = provider.getUser(SomeUser, SOME_KEY); + const value = { foo: true, bar: false }; + await outputState.update(() => value); + const dependency = new BehaviorSubject(false).asObservable(); + const bufferedState = new BufferedState(provider, BUFFER_KEY, outputState, dependency); + await provider.setUserState(BUFFER_KEY.toKeyDefinition(), { foo: true, bar: true }, SomeUser); + + const result = await firstValueFrom(bufferedState.state$); + + expect(result).toEqual(value); + }); + + // also important for data migrations + it("overwrites the output state when its dependency emits a truthy value", async () => { + const provider = new FakeStateProvider(accountService); + const outputState = provider.getUser(SomeUser, SOME_KEY); + const firstValue = { foo: true, bar: false }; + await outputState.update(() => firstValue); + const dependency = new BehaviorSubject(false); + const bufferedState = new BufferedState( + provider, + BUFFER_KEY, + outputState, + dependency.asObservable(), + ); + const bufferedValue = { foo: true, bar: true }; + await provider.setUserState(BUFFER_KEY.toKeyDefinition(), bufferedValue, SomeUser); + + const result = trackEmissions(bufferedState.state$); + dependency.next(true); + await awaitAsync(); + + expect(result).toEqual([firstValue, bufferedValue]); + }); + + it("overwrites the output state when shouldOverwrite returns a truthy value", async () => { + const bufferedKey = new BufferedKeyDefinition(GENERATOR_DISK, "fooBar_buffer", { + deserializer: (jsonValue) => jsonValue as SomeType, + shouldOverwrite: () => true, + clearOn: [], + }); + const provider = new FakeStateProvider(accountService); + const outputState = provider.getUser(SomeUser, SOME_KEY); + await outputState.update(() => ({ foo: true, bar: false })); + const bufferedState = new BufferedState(provider, bufferedKey, outputState); + const bufferedValue = { foo: true, bar: true }; + await provider.setUserState(bufferedKey.toKeyDefinition(), bufferedValue, SomeUser); + + const result = await firstValueFrom(bufferedState.state$); + + expect(result).toEqual(bufferedValue); + }); + + it("reads from the output state when shouldOverwrite returns a falsy value", async () => { + const bufferedKey = new BufferedKeyDefinition(GENERATOR_DISK, "fooBar_buffer", { + deserializer: (jsonValue) => jsonValue as SomeType, + shouldOverwrite: () => false, + clearOn: [], + }); + const provider = new FakeStateProvider(accountService); + const outputState = provider.getUser(SomeUser, SOME_KEY); + const value = { foo: true, bar: false }; + await outputState.update(() => value); + const bufferedState = new BufferedState(provider, bufferedKey, outputState); + await provider.setUserState( + bufferedKey.toKeyDefinition(), + { foo: true, bar: true }, + SomeUser, + ); + + const result = await firstValueFrom(bufferedState.state$); + + expect(result).toEqual(value); + }); + + it("replaces the output state when shouldOverwrite transforms its dependency to a truthy value", async () => { + const bufferedKey = new BufferedKeyDefinition(GENERATOR_DISK, "fooBar_buffer", { + deserializer: (jsonValue) => jsonValue as SomeType, + shouldOverwrite: (dependency) => !dependency, + clearOn: [], + }); + const provider = new FakeStateProvider(accountService); + const outputState = provider.getUser(SomeUser, SOME_KEY); + const firstValue = { foo: true, bar: false }; + await outputState.update(() => firstValue); + const dependency = new BehaviorSubject(true); + const bufferedState = new BufferedState( + provider, + bufferedKey, + outputState, + dependency.asObservable(), + ); + const bufferedValue = { foo: true, bar: true }; + await provider.setUserState(bufferedKey.toKeyDefinition(), bufferedValue, SomeUser); + + const result = trackEmissions(bufferedState.state$); + dependency.next(false); + await awaitAsync(); + + expect(result).toEqual([firstValue, bufferedValue]); + }); + }); + + describe("userId", () => { + const AnotherUser = "anotherUser" as UserId; + + it.each([[SomeUser], [AnotherUser]])("gets the userId", (userId) => { + const provider = new FakeStateProvider(accountService); + const outputState = provider.getUser(userId, SOME_KEY); + const bufferedState = new BufferedState(provider, BUFFER_KEY, outputState); + + const result = bufferedState.userId; + + expect(result).toEqual(userId); + }); + }); + + describe("update", () => { + it("updates state$", async () => { + const provider = new FakeStateProvider(accountService); + const outputState = provider.getUser(SomeUser, SOME_KEY); + const firstValue = { foo: true, bar: false }; + const secondValue = { foo: true, bar: true }; + await outputState.update(() => firstValue); + const bufferedState = new BufferedState(provider, BUFFER_KEY, outputState); + + const result = trackEmissions(bufferedState.state$); + await bufferedState.update(() => secondValue); + await awaitAsync(); + + expect(result).toEqual([firstValue, secondValue]); + }); + + it("respects update options", async () => { + const provider = new FakeStateProvider(accountService); + const outputState = provider.getUser(SomeUser, SOME_KEY); + const firstValue = { foo: true, bar: false }; + const secondValue = { foo: true, bar: true }; + await outputState.update(() => firstValue); + const bufferedState = new BufferedState(provider, BUFFER_KEY, outputState); + + const result = trackEmissions(bufferedState.state$); + await bufferedState.update(() => secondValue, { + shouldUpdate: (_, latest) => latest, + combineLatestWith: of(false), + }); + await awaitAsync(); + + expect(result).toEqual([firstValue]); + }); + }); + + describe("buffer", () => { + it("updates state$ once per overwrite", async () => { + const provider = new FakeStateProvider(accountService); + const outputState = provider.getUser(SomeUser, SOME_KEY); + const firstValue = { foo: true, bar: false }; + const secondValue = { foo: true, bar: true }; + await outputState.update(() => firstValue); + const bufferedState = new BufferedState(provider, BUFFER_KEY, outputState); + + const result = trackEmissions(bufferedState.state$); + await bufferedState.buffer(secondValue); + await awaitAsync(); + + expect(result).toEqual([firstValue, secondValue]); + }); + + it("emits the output state when its dependency is false", async () => { + const provider = new FakeStateProvider(accountService); + const outputState = provider.getUser(SomeUser, SOME_KEY); + const firstValue = { foo: true, bar: false }; + await outputState.update(() => firstValue); + const dependency = new BehaviorSubject(false); + const bufferedState = new BufferedState( + provider, + BUFFER_KEY, + outputState, + dependency.asObservable(), + ); + const bufferedValue = { foo: true, bar: true }; + + const result = trackEmissions(bufferedState.state$); + await bufferedState.buffer(bufferedValue); + await awaitAsync(); + + expect(result).toEqual([firstValue, firstValue]); + }); + + it("replaces the output state when its dependency becomes true", async () => { + const provider = new FakeStateProvider(accountService); + const outputState = provider.getUser(SomeUser, SOME_KEY); + const firstValue = { foo: true, bar: false }; + await outputState.update(() => firstValue); + const dependency = new BehaviorSubject(false); + const bufferedState = new BufferedState( + provider, + BUFFER_KEY, + outputState, + dependency.asObservable(), + ); + const bufferedValue = { foo: true, bar: true }; + + const result = trackEmissions(bufferedState.state$); + await bufferedState.buffer(bufferedValue); + dependency.next(true); + await awaitAsync(); + + expect(result).toEqual([firstValue, firstValue, bufferedValue]); + }); + + it.each([[null], [undefined]])("ignores `%p`", async (bufferedValue) => { + const provider = new FakeStateProvider(accountService); + const outputState = provider.getUser(SomeUser, SOME_KEY); + const firstValue = { foo: true, bar: false }; + await outputState.update(() => firstValue); + const bufferedState = new BufferedState(provider, BUFFER_KEY, outputState); + + const result = trackEmissions(bufferedState.state$); + await bufferedState.buffer(bufferedValue); + await awaitAsync(); + + expect(result).toEqual([firstValue]); + }); + + it("discards the buffered data when isValid returns false", async () => { + const bufferedKey = new BufferedKeyDefinition(GENERATOR_DISK, "fooBar_buffer", { + deserializer: (jsonValue) => jsonValue as SomeType, + isValid: () => Promise.resolve(false), + clearOn: [], + }); + const provider = new FakeStateProvider(accountService); + const outputState = provider.getUser(SomeUser, SOME_KEY); + const firstValue = { foo: true, bar: false }; + await outputState.update(() => firstValue); + const bufferedState = new BufferedState(provider, bufferedKey, outputState); + + const result = trackEmissions(bufferedState.state$); + await bufferedState.buffer({ foo: true, bar: true }); + await awaitAsync(); + + expect(result).toEqual([firstValue, firstValue]); + }); + + it("overwrites the output when isValid returns true", async () => { + const bufferedKey = new BufferedKeyDefinition(GENERATOR_DISK, "fooBar_buffer", { + deserializer: (jsonValue) => jsonValue as SomeType, + isValid: () => Promise.resolve(true), + clearOn: [], + }); + const provider = new FakeStateProvider(accountService); + const outputState = provider.getUser(SomeUser, SOME_KEY); + const firstValue = { foo: true, bar: false }; + await outputState.update(() => firstValue); + const bufferedState = new BufferedState(provider, bufferedKey, outputState); + const bufferedValue = { foo: true, bar: true }; + + const result = trackEmissions(bufferedState.state$); + await bufferedState.buffer(bufferedValue); + await awaitAsync(); + + expect(result).toEqual([firstValue, bufferedValue]); + }); + + it("maps the buffered data when it overwrites the state", async () => { + const mappedValue = { foo: true, bar: true }; + const bufferedKey = new BufferedKeyDefinition(GENERATOR_DISK, "fooBar_buffer", { + deserializer: (jsonValue) => jsonValue as SomeType, + map: () => Promise.resolve(mappedValue), + clearOn: [], + }); + const provider = new FakeStateProvider(accountService); + const outputState = provider.getUser(SomeUser, SOME_KEY); + const firstValue = { foo: true, bar: false }; + await outputState.update(() => firstValue); + const bufferedState = new BufferedState(provider, bufferedKey, outputState); + + const result = trackEmissions(bufferedState.state$); + await bufferedState.buffer({ foo: false, bar: false }); + await awaitAsync(); + + expect(result).toEqual([firstValue, mappedValue]); + }); + }); +}); diff --git a/libs/common/src/tools/generator/state/buffered-state.ts b/libs/common/src/tools/generator/state/buffered-state.ts new file mode 100644 index 0000000000..42b14b815c --- /dev/null +++ b/libs/common/src/tools/generator/state/buffered-state.ts @@ -0,0 +1,144 @@ +import { Observable, combineLatest, concatMap, filter, map, of } from "rxjs"; + +import { + StateProvider, + SingleUserState, + CombinedState, + StateUpdateOptions, +} from "../../../platform/state"; + +import { BufferedKeyDefinition } from "./buffered-key-definition"; + +/** Stateful storage that overwrites one state with a buffered state. + * When a overwrite occurs, the input state is automatically deleted. + * @remarks The buffered state can only overwrite non-nullish values. If the + * buffer key contains `null` or `undefined`, it will do nothing. + */ +export class BufferedState implements SingleUserState { + /** + * Instantiate a buffered state + * @param provider constructs the buffer. + * @param key defines the buffer location. + * @param output updates when a overwrite occurs + * @param dependency$ provides data the buffer depends upon to evaluate and + * transform its data. If this is omitted, then `true` is injected as + * a dependency, which with a default output will trigger a overwrite immediately. + * + * @remarks `dependency$` enables overwrite control during dynamic circumstances, + * such as when a overwrite should occur only if a user key is available. + */ + constructor( + provider: StateProvider, + private key: BufferedKeyDefinition, + private output: SingleUserState, + dependency$: Observable = null, + ) { + this.bufferState = provider.getUser(output.userId, key.toKeyDefinition()); + + const watching = [ + this.bufferState.state$, + this.output.state$, + dependency$ ?? of(true as unknown as Dependency), + ] as const; + + this.state$ = combineLatest(watching).pipe( + concatMap(async ([input, output, dependency]) => { + const normalized = input ?? null; + + const canOverwrite = normalized !== null && key.shouldOverwrite(dependency); + if (canOverwrite) { + await this.updateOutput(dependency); + + // prevent duplicate updates by suppressing the update + return [false, output] as const; + } + + return [true, output] as const; + }), + filter(([updated]) => updated), + map(([, output]) => output), + ); + + this.combinedState$ = this.state$.pipe(map((state) => [this.output.userId, state])); + + this.bufferState$ = this.bufferState.state$; + } + + private bufferState: SingleUserState; + + private async updateOutput(dependency: Dependency) { + // retrieve the latest input value + let input: Input; + await this.bufferState.update((state) => state, { + shouldUpdate: (state) => { + input = state; + return false; + }, + }); + + // bail if this update lost the race with the last update + if (input === null) { + return; + } + + // destroy invalid data and bail + if (!(await this.key.isValid(input, dependency))) { + await this.bufferState.update(() => null); + return; + } + + // overwrite anything left to the output; the updates need to be awaited with `Promise.all` + // so that `inputState.update(() => null)` runs before `shouldUpdate` reads the value (above). + // This lets the emission from `this.outputState.update` renter the `concatMap`. If the + // awaits run in sequence, it can win the race and cause a double emission. + const output = await this.key.map(input, dependency); + await Promise.all([this.output.update(() => output), this.bufferState.update(() => null)]); + + return; + } + + /** {@link SingleUserState.userId} */ + get userId() { + return this.output.userId; + } + + /** Observes changes to the output state. This updates when the output + * state updates, when the buffer is moved to the output, and when `BufferedState.buffer` + * is invoked. + */ + readonly state$: Observable; + + /** {@link SingleUserState.combinedState$} */ + readonly combinedState$: Observable>; + + /** Buffers a value state. The buffered state overwrites the output + * state when a subscription occurs. + * @param value the state to roll over. Setting this to `null` or `undefined` + * has no effect. + */ + async buffer(value: Input): Promise { + const normalized = value ?? null; + if (normalized !== null) { + await this.bufferState.update(() => normalized); + } + } + + /** The data presently being buffered. This emits the pending value each time + * new buffer data is provided. It emits null when the buffer is empty. + */ + readonly bufferState$: Observable; + + /** Updates the output state. + * @param configureState a callback that returns an updated output + * state. The callback receives the state's present value as its + * first argument and the dependencies listed in `options.combinedLatestWith` + * as its second argument. + * @param options configures how the update is applied. See {@link StateUpdateOptions}. + */ + update( + configureState: (state: Output, dependencies: TCombine) => Output, + options: StateUpdateOptions = null, + ): Promise { + return this.output.update(configureState, options); + } +} diff --git a/libs/common/src/tools/send/services/send.service.spec.ts b/libs/common/src/tools/send/services/send.service.spec.ts index fc793dba67..41183c42af 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -8,7 +8,6 @@ import { awaitAsync, mockAccountServiceWith, } from "../../../../spec"; -import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; @@ -64,7 +63,6 @@ describe("SendService", () => { id: mockUserId, email: "email", name: "name", - status: AuthenticationStatus.Unlocked, }); // Initial encrypted state diff --git a/libs/common/src/types/guid.ts b/libs/common/src/types/guid.ts index 714f5dffc3..97c87e684e 100644 --- a/libs/common/src/types/guid.ts +++ b/libs/common/src/types/guid.ts @@ -8,3 +8,4 @@ export type CollectionId = Opaque; export type ProviderId = Opaque; export type PolicyId = Opaque; export type CipherId = Opaque; +export type IndexedEntityId = Opaque; diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index a8a0a25e9b..501fd87665 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -1,3 +1,5 @@ +import { Observable } from "rxjs"; + import { UriMatchStrategySetting } from "../../models/domain/domain-service"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; import { CipherId, CollectionId, OrganizationId } from "../../types/guid"; @@ -7,8 +9,13 @@ import { Cipher } from "../models/domain/cipher"; import { Field } from "../models/domain/field"; import { CipherView } from "../models/view/cipher.view"; import { FieldView } from "../models/view/field.view"; +import { AddEditCipherInfo } from "../types/add-edit-cipher-info"; export abstract class CipherService { + /** + * An observable monitoring the add/edit cipher info saved to memory. + */ + addEditCipherInfo$: Observable; clearCache: (userId?: string) => Promise; encrypt: ( model: CipherView, @@ -102,4 +109,5 @@ export abstract class CipherService { asAdmin?: boolean, ) => Promise; getKeyForCipherKeyDecryption: (cipher: Cipher) => Promise; + setAddEditCipherInfo: (value: AddEditCipherInfo) => Promise; } diff --git a/libs/common/src/vault/models/data/cipher.data.ts b/libs/common/src/vault/models/data/cipher.data.ts index 1452ffe7ee..f8db7186d6 100644 --- a/libs/common/src/vault/models/data/cipher.data.ts +++ b/libs/common/src/vault/models/data/cipher.data.ts @@ -1,3 +1,5 @@ +import { Jsonify } from "type-fest"; + import { CipherRepromptType } from "../../enums/cipher-reprompt-type"; import { CipherType } from "../../enums/cipher-type"; import { CipherResponse } from "../response/cipher.response"; @@ -84,4 +86,8 @@ export class CipherData { this.passwordHistory = response.passwordHistory.map((ph) => new PasswordHistoryData(ph)); } } + + static fromJSON(obj: Jsonify) { + return Object.assign(new CipherData(), obj); + } } diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index c374724781..28c4bfc653 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -1,6 +1,8 @@ import { mock } from "jest-mock-extended"; import { of } from "rxjs"; +import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service"; +import { FakeStateProvider } from "../../../spec/fake-state-provider"; import { makeStaticByteArray } from "../../../spec/utils"; import { ApiService } from "../../abstractions/api.service"; import { SearchService } from "../../abstractions/search.service"; @@ -12,10 +14,12 @@ import { CryptoService } from "../../platform/abstractions/crypto.service"; import { EncryptService } from "../../platform/abstractions/encrypt.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; import { StateService } from "../../platform/abstractions/state.service"; +import { Utils } from "../../platform/misc/utils"; import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer"; import { EncString } from "../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; import { ContainerService } from "../../platform/services/container.service"; +import { UserId } from "../../types/guid"; import { CipherKey, OrgKey } from "../../types/key"; import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service"; import { FieldType } from "../enums"; @@ -97,6 +101,8 @@ const cipherData: CipherData = { }, ], }; +const mockUserId = Utils.newGuid() as UserId; +let accountService: FakeAccountService; describe("Cipher Service", () => { const cryptoService = mock(); @@ -109,6 +115,8 @@ describe("Cipher Service", () => { const searchService = mock(); const encryptService = mock(); const configService = mock(); + accountService = mockAccountServiceWith(mockUserId); + const stateProvider = new FakeStateProvider(accountService); let cipherService: CipherService; let cipherObj: Cipher; @@ -130,6 +138,7 @@ describe("Cipher Service", () => { encryptService, cipherFileUploadService, configService, + stateProvider, ); cipherObj = new Cipher(cipherData); diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 4a6e96ead7..e8544d7f98 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1,4 +1,4 @@ -import { firstValueFrom } from "rxjs"; +import { Observable, firstValueFrom } from "rxjs"; import { SemVer } from "semver"; import { ApiService } from "../../abstractions/api.service"; @@ -21,13 +21,15 @@ import Domain from "../../platform/models/domain/domain-base"; import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer"; import { EncString } from "../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; +import { ActiveUserState, StateProvider } from "../../platform/state"; import { CipherId, CollectionId, OrganizationId } from "../../types/guid"; -import { OrgKey, UserKey } from "../../types/key"; +import { UserKey, OrgKey } from "../../types/key"; import { CipherService as CipherServiceAbstraction } from "../abstractions/cipher.service"; import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service"; import { FieldType } from "../enums"; import { CipherType } from "../enums/cipher-type"; import { CipherData } from "../models/data/cipher.data"; +import { LocalData } from "../models/data/local.data"; import { Attachment } from "../models/domain/attachment"; import { Card } from "../models/domain/card"; import { Cipher } from "../models/domain/cipher"; @@ -54,6 +56,14 @@ import { AttachmentView } from "../models/view/attachment.view"; import { CipherView } from "../models/view/cipher.view"; import { FieldView } from "../models/view/field.view"; import { PasswordHistoryView } from "../models/view/password-history.view"; +import { AddEditCipherInfo } from "../types/add-edit-cipher-info"; + +import { + ENCRYPTED_CIPHERS, + LOCAL_DATA_KEY, + ADD_EDIT_CIPHER_INFO_KEY, + DECRYPTED_CIPHERS, +} from "./key-state/ciphers.state"; const CIPHER_KEY_ENC_MIN_SERVER_VER = new SemVer("2024.2.0"); @@ -62,6 +72,16 @@ export class CipherService implements CipherServiceAbstraction { this.sortCiphersByLastUsed, ); + localData$: Observable>; + ciphers$: Observable>; + cipherViews$: Observable>; + addEditCipherInfo$: Observable; + + private localDataState: ActiveUserState>; + private encryptedCiphersState: ActiveUserState>; + private decryptedCiphersState: ActiveUserState>; + private addEditCipherInfoState: ActiveUserState; + constructor( private cryptoService: CryptoService, private domainSettingsService: DomainSettingsService, @@ -73,11 +93,17 @@ export class CipherService implements CipherServiceAbstraction { private encryptService: EncryptService, private cipherFileUploadService: CipherFileUploadService, private configService: ConfigService, - ) {} + private stateProvider: StateProvider, + ) { + this.localDataState = this.stateProvider.getActive(LOCAL_DATA_KEY); + this.encryptedCiphersState = this.stateProvider.getActive(ENCRYPTED_CIPHERS); + this.decryptedCiphersState = this.stateProvider.getActive(DECRYPTED_CIPHERS); + this.addEditCipherInfoState = this.stateProvider.getActive(ADD_EDIT_CIPHER_INFO_KEY); - async getDecryptedCipherCache(): Promise { - const decryptedCiphers = await this.stateService.getDecryptedCiphers(); - return decryptedCiphers; + this.localData$ = this.localDataState.state$; + this.ciphers$ = this.encryptedCiphersState.state$; + this.cipherViews$ = this.decryptedCiphersState.state$; + this.addEditCipherInfo$ = this.addEditCipherInfoState.state$; } async setDecryptedCipherCache(value: CipherView[]) { @@ -85,17 +111,25 @@ export class CipherService implements CipherServiceAbstraction { // if we cache it then we may accidentially return it when it's not right, we'd rather try decryption again. // We still want to set null though, that is the indicator that the cache isn't valid and we should do decryption. if (value == null || value.length !== 0) { - await this.stateService.setDecryptedCiphers(value); + await this.setDecryptedCiphers(value); } if (this.searchService != null) { if (value == null) { - this.searchService.clearIndex(); + await this.searchService.clearIndex(); } else { - this.searchService.indexCiphers(value); + await this.searchService.indexCiphers(value); } } } + private async setDecryptedCiphers(value: CipherView[]) { + const cipherViews: { [id: string]: CipherView } = {}; + value?.forEach((c) => { + cipherViews[c.id] = c; + }); + await this.decryptedCiphersState.update(() => cipherViews); + } + async clearCache(userId?: string): Promise { await this.clearDecryptedCiphersState(userId); } @@ -268,24 +302,27 @@ export class CipherService implements CipherServiceAbstraction { } async get(id: string): Promise { - const ciphers = await this.stateService.getEncryptedCiphers(); + const ciphers = await firstValueFrom(this.ciphers$); // eslint-disable-next-line if (ciphers == null || !ciphers.hasOwnProperty(id)) { return null; } - const localData = await this.stateService.getLocalData(); - return new Cipher(ciphers[id], localData ? localData[id] : null); + const localData = await firstValueFrom(this.localData$); + const cipherId = id as CipherId; + + return new Cipher(ciphers[cipherId], localData ? localData[cipherId] : null); } async getAll(): Promise { - const localData = await this.stateService.getLocalData(); - const ciphers = await this.stateService.getEncryptedCiphers(); + const localData = await firstValueFrom(this.localData$); + const ciphers = await firstValueFrom(this.ciphers$); const response: Cipher[] = []; for (const id in ciphers) { // eslint-disable-next-line if (ciphers.hasOwnProperty(id)) { - response.push(new Cipher(ciphers[id], localData ? localData[id] : null)); + const cipherId = id as CipherId; + response.push(new Cipher(ciphers[cipherId], localData ? localData[cipherId] : null)); } } return response; @@ -293,12 +330,23 @@ export class CipherService implements CipherServiceAbstraction { @sequentialize(() => "getAllDecrypted") async getAllDecrypted(): Promise { - if ((await this.getDecryptedCipherCache()) != null) { + let decCiphers = await this.getDecryptedCiphers(); + if (decCiphers != null && decCiphers.length !== 0) { await this.reindexCiphers(); - return await this.getDecryptedCipherCache(); + return await this.getDecryptedCiphers(); } - const ciphers = await this.getAll(); + decCiphers = await this.decryptCiphers(await this.getAll()); + + await this.setDecryptedCipherCache(decCiphers); + return decCiphers; + } + + private async getDecryptedCiphers() { + return Object.values(await firstValueFrom(this.cipherViews$)); + } + + private async decryptCiphers(ciphers: Cipher[]) { const orgKeys = await this.cryptoService.getOrgKeys(); const userKey = await this.cryptoService.getUserKeyWithLegacySupport(); if (Object.keys(orgKeys).length === 0 && userKey == null) { @@ -326,16 +374,16 @@ export class CipherService implements CipherServiceAbstraction { .flat() .sort(this.getLocaleSortingFunction()); - await this.setDecryptedCipherCache(decCiphers); return decCiphers; } private async reindexCiphers() { const userId = await this.stateService.getUserId(); const reindexRequired = - this.searchService != null && (this.searchService.indexedEntityId ?? userId) !== userId; + this.searchService != null && + ((await firstValueFrom(this.searchService.indexedEntityId$)) ?? userId) !== userId; if (reindexRequired) { - this.searchService.indexCiphers(await this.getDecryptedCipherCache(), userId); + await this.searchService.indexCiphers(await this.getDecryptedCiphers(), userId); } } @@ -447,22 +495,24 @@ export class CipherService implements CipherServiceAbstraction { } async updateLastUsedDate(id: string): Promise { - let ciphersLocalData = await this.stateService.getLocalData(); + let ciphersLocalData = await firstValueFrom(this.localData$); + if (!ciphersLocalData) { ciphersLocalData = {}; } - if (ciphersLocalData[id]) { - ciphersLocalData[id].lastUsedDate = new Date().getTime(); + const cipherId = id as CipherId; + if (ciphersLocalData[cipherId]) { + ciphersLocalData[cipherId].lastUsedDate = new Date().getTime(); } else { - ciphersLocalData[id] = { + ciphersLocalData[cipherId] = { lastUsedDate: new Date().getTime(), }; } - await this.stateService.setLocalData(ciphersLocalData); + await this.localDataState.update(() => ciphersLocalData); - const decryptedCipherCache = await this.stateService.getDecryptedCiphers(); + const decryptedCipherCache = await this.getDecryptedCiphers(); if (!decryptedCipherCache) { return; } @@ -470,30 +520,32 @@ export class CipherService implements CipherServiceAbstraction { for (let i = 0; i < decryptedCipherCache.length; i++) { const cached = decryptedCipherCache[i]; if (cached.id === id) { - cached.localData = ciphersLocalData[id]; + cached.localData = ciphersLocalData[id as CipherId]; break; } } - await this.stateService.setDecryptedCiphers(decryptedCipherCache); + await this.setDecryptedCiphers(decryptedCipherCache); } async updateLastLaunchedDate(id: string): Promise { - let ciphersLocalData = await this.stateService.getLocalData(); + let ciphersLocalData = await firstValueFrom(this.localData$); + if (!ciphersLocalData) { ciphersLocalData = {}; } - if (ciphersLocalData[id]) { - ciphersLocalData[id].lastLaunched = new Date().getTime(); + const cipherId = id as CipherId; + if (ciphersLocalData[cipherId]) { + ciphersLocalData[cipherId].lastLaunched = new Date().getTime(); } else { - ciphersLocalData[id] = { + ciphersLocalData[cipherId] = { lastUsedDate: new Date().getTime(), }; } - await this.stateService.setLocalData(ciphersLocalData); + await this.localDataState.update(() => ciphersLocalData); - const decryptedCipherCache = await this.stateService.getDecryptedCiphers(); + const decryptedCipherCache = await this.getDecryptedCiphers(); if (!decryptedCipherCache) { return; } @@ -501,11 +553,11 @@ export class CipherService implements CipherServiceAbstraction { for (let i = 0; i < decryptedCipherCache.length; i++) { const cached = decryptedCipherCache[i]; if (cached.id === id) { - cached.localData = ciphersLocalData[id]; + cached.localData = ciphersLocalData[id as CipherId]; break; } } - await this.stateService.setDecryptedCiphers(decryptedCipherCache); + await this.setDecryptedCiphers(decryptedCipherCache); } async saveNeverDomain(domain: string): Promise { @@ -682,8 +734,8 @@ export class CipherService implements CipherServiceAbstraction { async saveCollectionsWithServer(cipher: Cipher): Promise { const request = new CipherCollectionsRequest(cipher.collectionIds); - await this.apiService.putCipherCollections(cipher.id, request); - const data = cipher.toCipherData(); + const response = await this.apiService.putCipherCollections(cipher.id, request); + const data = new CipherData(response); await this.upsert(data); } @@ -710,7 +762,7 @@ export class CipherService implements CipherServiceAbstraction { await this.apiService.send("POST", "/ciphers/bulk-collections", request, true, false); // Update the local state - const ciphers = await this.stateService.getEncryptedCiphers(); + const ciphers = await firstValueFrom(this.ciphers$); for (const id of cipherIds) { const cipher = ciphers[id]; @@ -727,30 +779,29 @@ export class CipherService implements CipherServiceAbstraction { } await this.clearCache(); - await this.stateService.setEncryptedCiphers(ciphers); + await this.encryptedCiphersState.update(() => ciphers); } async upsert(cipher: CipherData | CipherData[]): Promise { - let ciphers = await this.stateService.getEncryptedCiphers(); - if (ciphers == null) { - ciphers = {}; - } - - if (cipher instanceof CipherData) { - const c = cipher as CipherData; - ciphers[c.id] = c; - } else { - (cipher as CipherData[]).forEach((c) => { - ciphers[c.id] = c; - }); - } - - await this.replace(ciphers); + const ciphers = cipher instanceof CipherData ? [cipher] : cipher; + await this.updateEncryptedCipherState((current) => { + ciphers.forEach((c) => current[c.id as CipherId]); + return current; + }); } async replace(ciphers: { [id: string]: CipherData }): Promise { + await this.updateEncryptedCipherState(() => ciphers); + } + + private async updateEncryptedCipherState( + update: (current: Record) => Record, + ) { await this.clearDecryptedCiphersState(); - await this.stateService.setEncryptedCiphers(ciphers); + await this.encryptedCiphersState.update((current) => { + const result = update(current ?? {}); + return result; + }); } async clear(userId?: string): Promise { @@ -761,7 +812,7 @@ export class CipherService implements CipherServiceAbstraction { async moveManyWithServer(ids: string[], folderId: string): Promise { await this.apiService.putMoveCiphers(new CipherBulkMoveRequest(ids, folderId)); - let ciphers = await this.stateService.getEncryptedCiphers(); + let ciphers = await firstValueFrom(this.ciphers$); if (ciphers == null) { ciphers = {}; } @@ -769,33 +820,34 @@ export class CipherService implements CipherServiceAbstraction { ids.forEach((id) => { // eslint-disable-next-line if (ciphers.hasOwnProperty(id)) { - ciphers[id].folderId = folderId; + ciphers[id as CipherId].folderId = folderId; } }); await this.clearCache(); - await this.stateService.setEncryptedCiphers(ciphers); + await this.encryptedCiphersState.update(() => ciphers); } async delete(id: string | string[]): Promise { - const ciphers = await this.stateService.getEncryptedCiphers(); + const ciphers = await firstValueFrom(this.ciphers$); if (ciphers == null) { return; } if (typeof id === "string") { - if (ciphers[id] == null) { + const cipherId = id as CipherId; + if (ciphers[cipherId] == null) { return; } - delete ciphers[id]; + delete ciphers[cipherId]; } else { - (id as string[]).forEach((i) => { + (id as CipherId[]).forEach((i) => { delete ciphers[i]; }); } await this.clearCache(); - await this.stateService.setEncryptedCiphers(ciphers); + await this.encryptedCiphersState.update(() => ciphers); } async deleteWithServer(id: string, asAdmin = false): Promise { @@ -819,21 +871,26 @@ export class CipherService implements CipherServiceAbstraction { } async deleteAttachment(id: string, attachmentId: string): Promise { - const ciphers = await this.stateService.getEncryptedCiphers(); - + let ciphers = await firstValueFrom(this.ciphers$); + const cipherId = id as CipherId; // eslint-disable-next-line - if (ciphers == null || !ciphers.hasOwnProperty(id) || ciphers[id].attachments == null) { + if (ciphers == null || !ciphers.hasOwnProperty(id) || ciphers[cipherId].attachments == null) { return; } - for (let i = 0; i < ciphers[id].attachments.length; i++) { - if (ciphers[id].attachments[i].id === attachmentId) { - ciphers[id].attachments.splice(i, 1); + for (let i = 0; i < ciphers[cipherId].attachments.length; i++) { + if (ciphers[cipherId].attachments[i].id === attachmentId) { + ciphers[cipherId].attachments.splice(i, 1); } } await this.clearCache(); - await this.stateService.setEncryptedCiphers(ciphers); + await this.encryptedCiphersState.update(() => { + if (ciphers == null) { + ciphers = {}; + } + return ciphers; + }); } async deleteAttachmentWithServer(id: string, attachmentId: string): Promise { @@ -916,12 +973,12 @@ export class CipherService implements CipherServiceAbstraction { } async softDelete(id: string | string[]): Promise { - const ciphers = await this.stateService.getEncryptedCiphers(); + let ciphers = await firstValueFrom(this.ciphers$); if (ciphers == null) { return; } - const setDeletedDate = (cipherId: string) => { + const setDeletedDate = (cipherId: CipherId) => { if (ciphers[cipherId] == null) { return; } @@ -929,13 +986,18 @@ export class CipherService implements CipherServiceAbstraction { }; if (typeof id === "string") { - setDeletedDate(id); + setDeletedDate(id as CipherId); } else { (id as string[]).forEach(setDeletedDate); } await this.clearCache(); - await this.stateService.setEncryptedCiphers(ciphers); + await this.encryptedCiphersState.update(() => { + if (ciphers == null) { + ciphers = {}; + } + return ciphers; + }); } async softDeleteWithServer(id: string, asAdmin = false): Promise { @@ -962,17 +1024,18 @@ export class CipherService implements CipherServiceAbstraction { async restore( cipher: { id: string; revisionDate: string } | { id: string; revisionDate: string }[], ) { - const ciphers = await this.stateService.getEncryptedCiphers(); + let ciphers = await firstValueFrom(this.ciphers$); if (ciphers == null) { return; } const clearDeletedDate = (c: { id: string; revisionDate: string }) => { - if (ciphers[c.id] == null) { + const cipherId = c.id as CipherId; + if (ciphers[cipherId] == null) { return; } - ciphers[c.id].deletedDate = null; - ciphers[c.id].revisionDate = c.revisionDate; + ciphers[cipherId].deletedDate = null; + ciphers[cipherId].revisionDate = c.revisionDate; }; if (cipher.constructor.name === Array.name) { @@ -982,7 +1045,12 @@ export class CipherService implements CipherServiceAbstraction { } await this.clearCache(); - await this.stateService.setEncryptedCiphers(ciphers); + await this.encryptedCiphersState.update(() => { + if (ciphers == null) { + ciphers = {}; + } + return ciphers; + }); } async restoreWithServer(id: string, asAdmin = false): Promise { @@ -1024,6 +1092,10 @@ export class CipherService implements CipherServiceAbstraction { ); } + async setAddEditCipherInfo(value: AddEditCipherInfo) { + await this.addEditCipherInfoState.update(() => value); + } + // Helpers // In the case of a cipher that is being shared with an organization, we want to decrypt the @@ -1349,11 +1421,11 @@ export class CipherService implements CipherServiceAbstraction { } private async clearEncryptedCiphersState(userId?: string) { - await this.stateService.setEncryptedCiphers(null, { userId: userId }); + await this.encryptedCiphersState.update(() => ({})); } private async clearDecryptedCiphersState(userId?: string) { - await this.stateService.setDecryptedCiphers(null, { userId: userId }); + await this.setDecryptedCiphers(null); this.clearSortedCiphers(); } diff --git a/libs/common/src/vault/services/folder/folder.service.spec.ts b/libs/common/src/vault/services/folder/folder.service.spec.ts index 88595720e2..8c3be9abe8 100644 --- a/libs/common/src/vault/services/folder/folder.service.spec.ts +++ b/libs/common/src/vault/services/folder/folder.service.spec.ts @@ -8,7 +8,6 @@ import { FakeStateProvider } from "../../../../spec/fake-state-provider"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; -import { StateService } from "../../../platform/abstractions/state.service"; import { Utils } from "../../../platform/misc/utils"; import { EncString } from "../../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; @@ -27,7 +26,6 @@ describe("Folder Service", () => { let encryptService: MockProxy; let i18nService: MockProxy; let cipherService: MockProxy; - let stateService: MockProxy; let stateProvider: FakeStateProvider; const mockUserId = Utils.newGuid() as UserId; @@ -39,7 +37,6 @@ describe("Folder Service", () => { encryptService = mock(); i18nService = mock(); cipherService = mock(); - stateService = mock(); accountService = mockAccountServiceWith(mockUserId); stateProvider = new FakeStateProvider(accountService); @@ -52,13 +49,7 @@ describe("Folder Service", () => { ); encryptService.decryptToUtf8.mockResolvedValue("DEC"); - folderService = new FolderService( - cryptoService, - i18nService, - cipherService, - stateService, - stateProvider, - ); + folderService = new FolderService(cryptoService, i18nService, cipherService, stateProvider); folderState = stateProvider.activeUser.getFake(FOLDER_ENCRYPTED_FOLDERS); diff --git a/libs/common/src/vault/services/folder/folder.service.ts b/libs/common/src/vault/services/folder/folder.service.ts index afe3b01c68..584567aee8 100644 --- a/libs/common/src/vault/services/folder/folder.service.ts +++ b/libs/common/src/vault/services/folder/folder.service.ts @@ -2,17 +2,16 @@ import { Observable, firstValueFrom, map } from "rxjs"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; -import { StateService } from "../../../platform/abstractions/state.service"; import { Utils } from "../../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { ActiveUserState, DerivedState, StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { CipherService } from "../../../vault/abstractions/cipher.service"; import { InternalFolderService as InternalFolderServiceAbstraction } from "../../../vault/abstractions/folder/folder.service.abstraction"; -import { CipherData } from "../../../vault/models/data/cipher.data"; import { FolderData } from "../../../vault/models/data/folder.data"; import { Folder } from "../../../vault/models/domain/folder"; import { FolderView } from "../../../vault/models/view/folder.view"; +import { Cipher } from "../../models/domain/cipher"; import { FOLDER_DECRYPTED_FOLDERS, FOLDER_ENCRYPTED_FOLDERS } from "../key-state/folder.state"; export class FolderService implements InternalFolderServiceAbstraction { @@ -26,7 +25,6 @@ export class FolderService implements InternalFolderServiceAbstraction { private cryptoService: CryptoService, private i18nService: I18nService, private cipherService: CipherService, - private stateService: StateService, private stateProvider: StateProvider, ) { this.encryptedFoldersState = this.stateProvider.getActive(FOLDER_ENCRYPTED_FOLDERS); @@ -144,9 +142,9 @@ export class FolderService implements InternalFolderServiceAbstraction { }); // Items in a deleted folder are re-assigned to "No Folder" - const ciphers = await this.stateService.getEncryptedCiphers(); + const ciphers = await this.cipherService.getAll(); if (ciphers != null) { - const updates: CipherData[] = []; + const updates: Cipher[] = []; for (const cId in ciphers) { if (ciphers[cId].folderId === id) { ciphers[cId].folderId = null; @@ -156,7 +154,7 @@ export class FolderService implements InternalFolderServiceAbstraction { if (updates.length > 0) { // 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.cipherService.upsert(updates); + this.cipherService.upsert(updates.map((c) => c.toCipherData())); } } } diff --git a/libs/common/src/vault/services/key-state/ciphers.state.ts b/libs/common/src/vault/services/key-state/ciphers.state.ts new file mode 100644 index 0000000000..71da4c2333 --- /dev/null +++ b/libs/common/src/vault/services/key-state/ciphers.state.ts @@ -0,0 +1,52 @@ +import { Jsonify } from "type-fest"; + +import { + CIPHERS_DISK, + CIPHERS_DISK_LOCAL, + CIPHERS_MEMORY, + KeyDefinition, +} from "../../../platform/state"; +import { CipherId } from "../../../types/guid"; +import { CipherData } from "../../models/data/cipher.data"; +import { LocalData } from "../../models/data/local.data"; +import { CipherView } from "../../models/view/cipher.view"; +import { AddEditCipherInfo } from "../../types/add-edit-cipher-info"; + +export const ENCRYPTED_CIPHERS = KeyDefinition.record(CIPHERS_DISK, "ciphers", { + deserializer: (obj: Jsonify) => CipherData.fromJSON(obj), +}); + +export const DECRYPTED_CIPHERS = KeyDefinition.record( + CIPHERS_MEMORY, + "decryptedCiphers", + { + deserializer: (cipher: Jsonify) => CipherView.fromJSON(cipher), + }, +); + +export const LOCAL_DATA_KEY = new KeyDefinition>( + CIPHERS_DISK_LOCAL, + "localData", + { + deserializer: (localData) => localData, + }, +); + +export const ADD_EDIT_CIPHER_INFO_KEY = new KeyDefinition( + CIPHERS_MEMORY, + "addEditCipherInfo", + { + deserializer: (addEditCipherInfo: AddEditCipherInfo) => { + if (addEditCipherInfo == null) { + return null; + } + + const cipher = + addEditCipherInfo?.cipher.toJSON != null + ? addEditCipherInfo.cipher + : CipherView.fromJSON(addEditCipherInfo?.cipher as Jsonify); + + return { cipher, collectionIds: addEditCipherInfo.collectionIds }; + }, + }, +); diff --git a/libs/common/src/vault/services/sync/sync.service.ts b/libs/common/src/vault/services/sync/sync.service.ts index d4601d9621..ff8e9f1f4f 100644 --- a/libs/common/src/vault/services/sync/sync.service.ts +++ b/libs/common/src/vault/services/sync/sync.service.ts @@ -11,8 +11,10 @@ import { OrganizationData } from "../../../admin-console/models/data/organizatio import { PolicyData } from "../../../admin-console/models/data/policy.data"; import { ProviderData } from "../../../admin-console/models/data/provider.data"; import { PolicyResponse } from "../../../admin-console/models/response/policy.response"; +import { AccountService } from "../../../auth/abstractions/account.service"; import { AvatarService } from "../../../auth/abstractions/avatar.service"; import { KeyConnectorService } from "../../../auth/abstractions/key-connector.service"; +import { InternalMasterPasswordServiceAbstraction } from "../../../auth/abstractions/master-password.service.abstraction"; import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-password-reason"; import { DomainSettingsService } from "../../../autofill/services/domain-settings.service"; import { BillingAccountProfileStateService } from "../../../billing/abstractions/account/billing-account-profile-state.service"; @@ -49,6 +51,8 @@ export class SyncService implements SyncServiceAbstraction { syncInProgress = false; constructor( + private masterPasswordService: InternalMasterPasswordServiceAbstraction, + private accountService: AccountService, private apiService: ApiService, private domainSettingsService: DomainSettingsService, private folderService: InternalFolderService, @@ -352,8 +356,10 @@ export class SyncService implements SyncServiceAbstraction { private async setForceSetPasswordReasonIfNeeded(profileResponse: ProfileResponse) { // The `forcePasswordReset` flag indicates an admin has reset the user's password and must be updated if (profileResponse.forcePasswordReset) { - await this.stateService.setForceSetPasswordReason( + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setForceSetPasswordReason( ForceSetPasswordReason.AdminForcePasswordReset, + userId, ); } @@ -387,8 +393,10 @@ export class SyncService implements SyncServiceAbstraction { ) { // TDE user w/out MP went from having no password reset permission to having it. // Must set the force password reset reason so the auth guard will redirect to the set password page. - await this.stateService.setForceSetPasswordReason( + const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + await this.masterPasswordService.setForceSetPasswordReason( ForceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission, + userId, ); } } diff --git a/package-lock.json b/package-lock.json index 4ec09e2b7c..c399536cca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -168,7 +168,7 @@ "regedit": "^3.0.3", "remark-gfm": "3.0.1", "rimraf": "5.0.5", - "sass": "1.69.5", + "sass": "1.74.1", "sass-loader": "13.3.3", "storybook": "7.6.17", "style-loader": "3.3.4", @@ -193,7 +193,7 @@ }, "apps/browser": { "name": "@bitwarden/browser", - "version": "2024.3.1" + "version": "2024.4.1" }, "apps/cli": { "name": "@bitwarden/cli", @@ -233,7 +233,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2024.3.2", + "version": "2024.4.1", "hasInstallScript": true, "license": "GPL-3.0" }, @@ -247,7 +247,7 @@ }, "apps/web": { "name": "@bitwarden/web-vault", - "version": "2024.3.1" + "version": "2024.4.0" }, "libs/admin-console": { "name": "@bitwarden/admin-console", @@ -33467,9 +33467,9 @@ } }, "node_modules/sass": { - "version": "1.69.5", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.69.5.tgz", - "integrity": "sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ==", + "version": "1.74.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.74.1.tgz", + "integrity": "sha512-w0Z9p/rWZWelb88ISOLyvqTWGmtmu2QJICqDBGyNnfG4OUnPX9BBjjYIXUpXCMOOg5MQWNpqzt876la1fsTvUA==", "dev": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", diff --git a/package.json b/package.json index 1c36865cd6..203da2d625 100644 --- a/package.json +++ b/package.json @@ -129,7 +129,7 @@ "regedit": "^3.0.3", "remark-gfm": "3.0.1", "rimraf": "5.0.5", - "sass": "1.69.5", + "sass": "1.74.1", "sass-loader": "13.3.3", "storybook": "7.6.17", "style-loader": "3.3.4",