From 85a5aea8974dc8769625dd1561368e44d5490c92 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Mon, 10 Mar 2025 10:33:56 -0500 Subject: [PATCH 01/35] [PM-18859] Mobile Viewports - Extension Prompt (#13703) * remove min-width on body element for extension prompt page * reset meta viewport content for extension prompt page * set max width of svg to avoid any overflow on mobile devices * use inline display to avoid icon overflow on mobile devices * use max width on the icon to fix overflow rather than editing the anon layout --- ...browser-extension-prompt.component.spec.ts | 49 ++++++++++++++++++- .../browser-extension-prompt.component.ts | 37 ++++++++++++-- libs/vault/src/icons/browser-extension.ts | 2 +- 3 files changed, 82 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.spec.ts b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.spec.ts index 40dbc0d442e..0bea6c186eb 100644 --- a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.spec.ts +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.spec.ts @@ -13,12 +13,27 @@ import { BrowserExtensionPromptComponent } from "./browser-extension-prompt.comp describe("BrowserExtensionPromptComponent", () => { let fixture: ComponentFixture; - + let component: BrowserExtensionPromptComponent; const start = jest.fn(); const pageState$ = new BehaviorSubject(BrowserPromptState.Loading); + const setAttribute = jest.fn(); + const getAttribute = jest.fn().mockReturnValue("width=1010"); beforeEach(async () => { start.mockClear(); + setAttribute.mockClear(); + getAttribute.mockClear(); + + // Store original querySelector + const originalQuerySelector = document.querySelector.bind(document); + + // Mock querySelector while preserving the document context + jest.spyOn(document, "querySelector").mockImplementation(function (selector) { + if (selector === 'meta[name="viewport"]') { + return { setAttribute, getAttribute } as unknown as HTMLMetaElement; + } + return originalQuerySelector.call(document, selector); + }); await TestBed.configureTestingModule({ providers: [ @@ -34,9 +49,14 @@ describe("BrowserExtensionPromptComponent", () => { }).compileComponents(); fixture = TestBed.createComponent(BrowserExtensionPromptComponent); + component = fixture.componentInstance; fixture.detectChanges(); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + it("calls start on initialization", () => { expect(start).toHaveBeenCalledTimes(1); }); @@ -87,6 +107,33 @@ describe("BrowserExtensionPromptComponent", () => { const mobileText = fixture.debugElement.query(By.css("p")).nativeElement; expect(mobileText.textContent.trim()).toBe("reopenLinkOnDesktop"); }); + + it("sets min-width on the body", () => { + expect(document.body.style.minWidth).toBe("auto"); + }); + + it("stores viewport content", () => { + expect(getAttribute).toHaveBeenCalledWith("content"); + expect(component["viewportContent"]).toBe("width=1010"); + }); + + it("sets viewport meta tag to be mobile friendly", () => { + expect(setAttribute).toHaveBeenCalledWith("content", "width=device-width, initial-scale=1.0"); + }); + + describe("on destroy", () => { + beforeEach(() => { + fixture.destroy(); + }); + + it("resets body min-width", () => { + expect(document.body.style.minWidth).toBe(""); + }); + + it("resets viewport meta tag", () => { + expect(setAttribute).toHaveBeenCalledWith("content", "width=1010"); + }); + }); }); describe("manual error state", () => { diff --git a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts index 640a1b0d771..4d3a5fa07dd 100644 --- a/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts +++ b/apps/web/src/app/vault/components/browser-extension-prompt/browser-extension-prompt.component.ts @@ -1,5 +1,5 @@ -import { CommonModule } from "@angular/common"; -import { Component, OnInit } from "@angular/core"; +import { CommonModule, DOCUMENT } from "@angular/common"; +import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; import { ButtonComponent, IconModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; @@ -16,7 +16,7 @@ import { standalone: true, imports: [CommonModule, I18nPipe, ButtonComponent, IconModule], }) -export class BrowserExtensionPromptComponent implements OnInit { +export class BrowserExtensionPromptComponent implements OnInit, OnDestroy { /** Current state of the prompt page */ protected pageState$ = this.browserExtensionPromptService.pageState$; @@ -25,10 +25,39 @@ export class BrowserExtensionPromptComponent implements OnInit { protected BitwardenIcon = VaultIcons.BitwardenIcon; - constructor(private browserExtensionPromptService: BrowserExtensionPromptService) {} + /** Content of the meta[name="viewport"] element */ + private viewportContent: string | null = null; + + constructor( + private browserExtensionPromptService: BrowserExtensionPromptService, + @Inject(DOCUMENT) private document: Document, + ) {} ngOnInit(): void { this.browserExtensionPromptService.start(); + + // It is not be uncommon for users to hit this page from a mobile device. + // There are global styles and the viewport meta tag that set a min-width + // for the page which cause it to render poorly. Remove them here. + // https://github.com/bitwarden/clients/blob/main/apps/web/src/scss/base.scss#L6 + this.document.body.style.minWidth = "auto"; + + const viewportMeta = this.document.querySelector('meta[name="viewport"]'); + + // Save the current viewport content to reset it when the component is destroyed + this.viewportContent = viewportMeta?.getAttribute("content") ?? null; + viewportMeta?.setAttribute("content", "width=device-width, initial-scale=1.0"); + } + + ngOnDestroy(): void { + // Reset the body min-width when the component is destroyed + this.document.body.style.minWidth = ""; + + if (this.viewportContent !== null) { + this.document + .querySelector('meta[name="viewport"]') + ?.setAttribute("content", this.viewportContent); + } } openExtension(): void { diff --git a/libs/vault/src/icons/browser-extension.ts b/libs/vault/src/icons/browser-extension.ts index f0f9b781491..ac54322292f 100644 --- a/libs/vault/src/icons/browser-extension.ts +++ b/libs/vault/src/icons/browser-extension.ts @@ -1,7 +1,7 @@ import { svgIcon } from "@bitwarden/components"; export const BrowserExtensionIcon = svgIcon` - + From 01f6fd7ee39911d5e9aca1696f74fdd3b3e46f1c Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Mon, 10 Mar 2025 18:41:47 +0100 Subject: [PATCH 02/35] [PM-16227] Move import to sdk and enable it in browser/web (#12479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Move import to sdk and enable it in browser/web * Add uncomitted files * Update package lock * Fix prettier formatting * Fix build * Rewrite import logic * Update ssh import logic for cipher form component * Fix build on browser * Break early in retry logic * Fix build * Fix build * Fix build errors * Update paste icons and throw error on wrong import * Fix tests * Fix build for cli * Undo change to jest config * Undo change to feature flag enum * Remove unneeded lifetime * Fix browser build * Refactor control flow * Fix i18n key and improve import behavior * Remove for loop limit * Clean up tests * Remove unused code * Update libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts Co-authored-by: SmithThe4th * Move import logic to service and add tests * Fix linting * Remove erroneous includes * Attempt to fix storybook * Fix storybook, explicitly implement ssh-import-prompt service abstraction * Fix eslint * Update libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts Co-authored-by: ✨ Audrey ✨ * Fix services module * Remove ssh import sdk init code * Add tests for errors * Fix import * Fix import * Fix pkcs8 encrypted key not parsing * Fix import button showing on web --------- Co-authored-by: SmithThe4th Co-authored-by: ✨ Audrey ✨ --- apps/browser/src/_locales/en/messages.json | 27 ++ .../browser/src/background/main.background.ts | 1 + .../src/popup/services/services.module.ts | 11 +- .../service-container/service-container.ts | 1 + .../core/src/ssh_agent/importer.rs | 402 ------------------ .../desktop_native/core/src/ssh_agent/mod.rs | 1 - apps/desktop/desktop_native/napi/index.d.ts | 17 - apps/desktop/desktop_native/napi/src/lib.rs | 68 --- .../src/app/services/services.module.ts | 6 + .../autofill/main/main-ssh-agent.service.ts | 10 - apps/desktop/src/locales/en/messages.json | 5 +- apps/desktop/src/platform/preload.ts | 8 - .../vault/app/vault/add-edit.component.html | 19 +- .../src/vault/app/vault/add-edit.component.ts | 70 +-- apps/web/src/app/core/core.module.ts | 7 + .../individual-vault/add-edit.component.ts | 4 +- .../app/vault/org-vault/add-edit.component.ts | 4 +- apps/web/src/locales/en/messages.json | 68 ++- .../vault/components/add-edit.component.ts | 14 +- libs/angular/tsconfig.json | 2 + .../common/src/models/export/cipher.export.ts | 1 + .../src/models/export/ssh-key.export.ts | 15 +- libs/common/tsconfig.json | 1 + libs/importer/jest.config.js | 2 +- .../src/components/import.component.ts | 2 + .../src/services/import.service.spec.ts | 8 + libs/importer/src/services/import.service.ts | 2 + .../src/cipher-form/cipher-form.stories.ts | 9 + .../sshkey-section.component.html | 8 + .../sshkey-section.component.ts | 25 +- libs/vault/src/index.ts | 4 +- .../default-ssh-import-prompt.service.ts | 109 +++++ .../ssh-import-prompt.service.spec.ts | 111 +++++ .../src/services/ssh-import-prompt.service.ts | 5 + libs/vault/tsconfig.json | 2 + 35 files changed, 428 insertions(+), 621 deletions(-) delete mode 100644 apps/desktop/desktop_native/core/src/ssh_agent/importer.rs create mode 100644 libs/vault/src/services/default-ssh-import-prompt.service.ts create mode 100644 libs/vault/src/services/ssh-import-prompt.service.spec.ts create mode 100644 libs/vault/src/services/ssh-import-prompt.service.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 67b770230cd..ae82892f1ed 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5128,6 +5128,33 @@ "extraWide": { "message": "Extra wide" }, + "sshKeyWrongPassword": { + "message": "The password you entered is incorrect." + }, + "importSshKey": { + "message": "Import" + }, + "confirmSshKeyPassword": { + "message": "Confirm password" + }, + "enterSshKeyPasswordDesc": { + "message": "Enter the password for the SSH key." + }, + "enterSshKeyPassword": { + "message": "Enter password" + }, + "invalidSshKey": { + "message": "The SSH key is invalid" + }, + "sshKeyTypeUnsupported": { + "message": "The SSH key type is not supported" + }, + "importSshKeyFromClipboard": { + "message": "Import key from clipboard" + }, + "sshKeyImported": { + "message": "SSH key imported successfully" + }, "cannotRemoveViewOnlyCollections": { "message": "You cannot remove collections with View only permissions: $COLLECTIONS$", "placeholders": { diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index cd65220936e..a5967b5fe76 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -1012,6 +1012,7 @@ export default class MainBackground { this.encryptService, this.pinService, this.accountService, + this.sdkService, ); this.individualVaultExportService = new IndividualVaultExportService( diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index d49f48c0c64..5a2adfcf62b 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -130,7 +130,11 @@ import { KeyService, } from "@bitwarden/key-management"; import { LockComponentService } from "@bitwarden/key-management-ui"; -import { PasswordRepromptService } from "@bitwarden/vault"; +import { + DefaultSshImportPromptService, + PasswordRepromptService, + SshImportPromptService, +} from "@bitwarden/vault"; import { ForegroundLockService } from "../../auth/popup/accounts/foreground-lock.service"; import { ExtensionAnonLayoutWrapperDataService } from "../../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service"; @@ -653,6 +657,11 @@ const safeProviders: SafeProvider[] = [ useClass: ExtensionLoginDecryptionOptionsService, deps: [MessagingServiceAbstraction, Router], }), + safeProvider({ + provide: SshImportPromptService, + useClass: DefaultSshImportPromptService, + deps: [DialogService, ToastService, PlatformUtilsService, I18nServiceAbstraction], + }), ]; @NgModule({ diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 0e776375e6a..555337736c7 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -780,6 +780,7 @@ export class ServiceContainer { this.encryptService, this.pinService, this.accountService, + this.sdkService, ); this.individualExportService = new IndividualVaultExportService( diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/importer.rs b/apps/desktop/desktop_native/core/src/ssh_agent/importer.rs deleted file mode 100644 index 52464487ec5..00000000000 --- a/apps/desktop/desktop_native/core/src/ssh_agent/importer.rs +++ /dev/null @@ -1,402 +0,0 @@ -use ed25519; -use pkcs8::{ - der::Decode, EncryptedPrivateKeyInfo, ObjectIdentifier, PrivateKeyInfo, SecretDocument, -}; -use ssh_key::{ - private::{Ed25519Keypair, Ed25519PrivateKey, RsaKeypair}, - HashAlg, LineEnding, -}; - -const PKCS1_HEADER: &str = "-----BEGIN RSA PRIVATE KEY-----"; -const PKCS8_UNENCRYPTED_HEADER: &str = "-----BEGIN PRIVATE KEY-----"; -const PKCS8_ENCRYPTED_HEADER: &str = "-----BEGIN ENCRYPTED PRIVATE KEY-----"; -const OPENSSH_HEADER: &str = "-----BEGIN OPENSSH PRIVATE KEY-----"; - -pub const RSA_PKCS8_ALGORITHM_OID: ObjectIdentifier = - ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.1"); - -#[derive(Debug)] -enum KeyType { - Ed25519, - Rsa, - Unknown, -} - -pub fn import_key( - encoded_key: String, - password: String, -) -> Result { - match encoded_key.lines().next() { - Some(PKCS1_HEADER) => Ok(SshKeyImportResult { - status: SshKeyImportStatus::UnsupportedKeyType, - ssh_key: None, - }), - Some(PKCS8_UNENCRYPTED_HEADER) => match import_pkcs8_key(encoded_key, None) { - Ok(result) => Ok(result), - Err(_) => Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }), - }, - Some(PKCS8_ENCRYPTED_HEADER) => match import_pkcs8_key(encoded_key, Some(password)) { - Ok(result) => Ok(result), - Err(err) => match err { - SshKeyImportError::PasswordRequired => Ok(SshKeyImportResult { - status: SshKeyImportStatus::PasswordRequired, - ssh_key: None, - }), - SshKeyImportError::WrongPassword => Ok(SshKeyImportResult { - status: SshKeyImportStatus::WrongPassword, - ssh_key: None, - }), - SshKeyImportError::ParsingError => Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }), - }, - }, - Some(OPENSSH_HEADER) => import_openssh_key(encoded_key, password), - Some(_) => Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }), - None => Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }), - } -} - -fn import_pkcs8_key( - encoded_key: String, - password: Option, -) -> Result { - let der = match SecretDocument::from_pem(&encoded_key) { - Ok((_, doc)) => doc, - Err(_) => { - return Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }); - } - }; - - let decrypted_der = match password.clone() { - Some(password) => { - let encrypted_private_key_info = match EncryptedPrivateKeyInfo::from_der(der.as_bytes()) - { - Ok(info) => info, - Err(_) => { - return Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }); - } - }; - match encrypted_private_key_info.decrypt(password.as_bytes()) { - Ok(der) => der, - Err(_) => { - return Ok(SshKeyImportResult { - status: SshKeyImportStatus::WrongPassword, - ssh_key: None, - }); - } - } - } - None => der, - }; - - let key_type: KeyType = match PrivateKeyInfo::from_der(decrypted_der.as_bytes()) - .map_err(|_| SshKeyImportError::ParsingError)? - .algorithm - .oid - { - ed25519::pkcs8::ALGORITHM_OID => KeyType::Ed25519, - RSA_PKCS8_ALGORITHM_OID => KeyType::Rsa, - _ => KeyType::Unknown, - }; - - match key_type { - KeyType::Ed25519 => { - let pk: ed25519::KeypairBytes = match password { - Some(password) => { - pkcs8::DecodePrivateKey::from_pkcs8_encrypted_pem(&encoded_key, password) - .map_err(|err| match err { - ed25519::pkcs8::Error::EncryptedPrivateKey(_) => { - SshKeyImportError::WrongPassword - } - _ => SshKeyImportError::ParsingError, - })? - } - None => ed25519::pkcs8::DecodePrivateKey::from_pkcs8_pem(&encoded_key) - .map_err(|_| SshKeyImportError::ParsingError)?, - }; - let pk: Ed25519Keypair = - Ed25519Keypair::from(Ed25519PrivateKey::from_bytes(&pk.secret_key)); - let private_key = ssh_key::private::PrivateKey::from(pk); - Ok(SshKeyImportResult { - status: SshKeyImportStatus::Success, - ssh_key: Some(SshKey { - private_key: private_key.to_openssh(LineEnding::LF).unwrap().to_string(), - public_key: private_key.public_key().to_string(), - key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(), - }), - }) - } - KeyType::Rsa => { - let pk: rsa::RsaPrivateKey = match password { - Some(password) => { - pkcs8::DecodePrivateKey::from_pkcs8_encrypted_pem(&encoded_key, password) - .map_err(|err| match err { - pkcs8::Error::EncryptedPrivateKey(_) => { - SshKeyImportError::WrongPassword - } - _ => SshKeyImportError::ParsingError, - })? - } - None => pkcs8::DecodePrivateKey::from_pkcs8_pem(&encoded_key) - .map_err(|_| SshKeyImportError::ParsingError)?, - }; - let rsa_keypair: Result = RsaKeypair::try_from(pk); - match rsa_keypair { - Ok(rsa_keypair) => { - let private_key = ssh_key::private::PrivateKey::from(rsa_keypair); - Ok(SshKeyImportResult { - status: SshKeyImportStatus::Success, - ssh_key: Some(SshKey { - private_key: private_key - .to_openssh(LineEnding::LF) - .unwrap() - .to_string(), - public_key: private_key.public_key().to_string(), - key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(), - }), - }) - } - Err(_) => Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }), - } - } - _ => Ok(SshKeyImportResult { - status: SshKeyImportStatus::UnsupportedKeyType, - ssh_key: None, - }), - } -} - -fn import_openssh_key( - encoded_key: String, - password: String, -) -> Result { - let private_key = ssh_key::private::PrivateKey::from_openssh(&encoded_key); - let private_key = match private_key { - Ok(k) => k, - Err(err) => { - match err { - ssh_key::Error::AlgorithmUnknown - | ssh_key::Error::AlgorithmUnsupported { algorithm: _ } => { - return Ok(SshKeyImportResult { - status: SshKeyImportStatus::UnsupportedKeyType, - ssh_key: None, - }); - } - _ => {} - } - return Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }); - } - }; - - if private_key.is_encrypted() && password.is_empty() { - return Ok(SshKeyImportResult { - status: SshKeyImportStatus::PasswordRequired, - ssh_key: None, - }); - } - let private_key = if private_key.is_encrypted() { - match private_key.decrypt(password.as_bytes()) { - Ok(k) => k, - Err(_) => { - return Ok(SshKeyImportResult { - status: SshKeyImportStatus::WrongPassword, - ssh_key: None, - }); - } - } - } else { - private_key - }; - - match private_key.to_openssh(LineEnding::LF) { - Ok(private_key_openssh) => Ok(SshKeyImportResult { - status: SshKeyImportStatus::Success, - ssh_key: Some(SshKey { - private_key: private_key_openssh.to_string(), - public_key: private_key.public_key().to_string(), - key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(), - }), - }), - Err(_) => Ok(SshKeyImportResult { - status: SshKeyImportStatus::ParsingError, - ssh_key: None, - }), - } -} - -#[derive(PartialEq, Debug)] -pub enum SshKeyImportStatus { - /// ssh key was parsed correctly and will be returned in the result - Success, - /// ssh key was parsed correctly but is encrypted and requires a password - PasswordRequired, - /// ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect - WrongPassword, - /// ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key - ParsingError, - /// ssh key type is not supported - UnsupportedKeyType, -} - -pub enum SshKeyImportError { - ParsingError, - PasswordRequired, - WrongPassword, -} - -pub struct SshKeyImportResult { - pub status: SshKeyImportStatus, - pub ssh_key: Option, -} - -pub struct SshKey { - pub private_key: String, - pub public_key: String, - pub key_fingerprint: String, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn import_key_ed25519_openssh_unencrypted() { - let private_key = include_str!("./test_keys/ed25519_openssh_unencrypted"); - let public_key = include_str!("./test_keys/ed25519_openssh_unencrypted.pub").trim(); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::Success); - assert_eq!(result.ssh_key.unwrap().public_key, public_key); - } - - #[test] - fn import_key_ed25519_openssh_encrypted() { - let private_key = include_str!("./test_keys/ed25519_openssh_encrypted"); - let public_key = include_str!("./test_keys/ed25519_openssh_encrypted.pub").trim(); - let result = import_key(private_key.to_string(), "password".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::Success); - assert_eq!(result.ssh_key.unwrap().public_key, public_key); - } - - #[test] - fn import_key_rsa_openssh_unencrypted() { - let private_key = include_str!("./test_keys/rsa_openssh_unencrypted"); - let public_key = include_str!("./test_keys/rsa_openssh_unencrypted.pub").trim(); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::Success); - assert_eq!(result.ssh_key.unwrap().public_key, public_key); - } - - #[test] - fn import_key_rsa_openssh_encrypted() { - let private_key = include_str!("./test_keys/rsa_openssh_encrypted"); - let public_key = include_str!("./test_keys/rsa_openssh_encrypted.pub").trim(); - let result = import_key(private_key.to_string(), "password".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::Success); - assert_eq!(result.ssh_key.unwrap().public_key, public_key); - } - - #[test] - fn import_key_ed25519_pkcs8_unencrypted() { - let private_key = include_str!("./test_keys/ed25519_pkcs8_unencrypted"); - let public_key = - include_str!("./test_keys/ed25519_pkcs8_unencrypted.pub").replace("testkey", ""); - let public_key = public_key.trim(); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::Success); - assert_eq!(result.ssh_key.unwrap().public_key, public_key); - } - - #[test] - fn import_key_rsa_pkcs8_unencrypted() { - let private_key = include_str!("./test_keys/rsa_pkcs8_unencrypted"); - // for whatever reason pkcs8 + rsa does not include the comment in the public key - let public_key = - include_str!("./test_keys/rsa_pkcs8_unencrypted.pub").replace("testkey", ""); - let public_key = public_key.trim(); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::Success); - assert_eq!(result.ssh_key.unwrap().public_key, public_key); - } - - #[test] - fn import_key_rsa_pkcs8_encrypted() { - let private_key = include_str!("./test_keys/rsa_pkcs8_encrypted"); - let public_key = include_str!("./test_keys/rsa_pkcs8_encrypted.pub").replace("testkey", ""); - let public_key = public_key.trim(); - let result = import_key(private_key.to_string(), "password".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::Success); - assert_eq!(result.ssh_key.unwrap().public_key, public_key); - } - - #[test] - fn import_key_ed25519_openssh_encrypted_wrong_password() { - let private_key = include_str!("./test_keys/ed25519_openssh_encrypted"); - let result = import_key(private_key.to_string(), "wrongpassword".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::WrongPassword); - } - - #[test] - fn import_non_key_error() { - let result = import_key("not a key".to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::ParsingError); - } - - #[test] - fn import_ecdsa_error() { - let private_key = include_str!("./test_keys/ecdsa_openssh_unencrypted"); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::UnsupportedKeyType); - } - - // Putty-exported keys should be supported, but are not due to a parser incompatibility. - // Should this test start failing, please change it to expect a correct key, and - // make sure the documentation support for putty-exported keys this is updated. - // https://bitwarden.atlassian.net/browse/PM-14989 - #[test] - fn import_key_ed25519_putty() { - let private_key = include_str!("./test_keys/ed25519_putty_openssh_unencrypted"); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::ParsingError); - } - - // Putty-exported keys should be supported, but are not due to a parser incompatibility. - // Should this test start failing, please change it to expect a correct key, and - // make sure the documentation support for putty-exported keys this is updated. - // https://bitwarden.atlassian.net/browse/PM-14989 - #[test] - fn import_key_rsa_openssh_putty() { - let private_key = include_str!("./test_keys/rsa_putty_openssh_unencrypted"); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::ParsingError); - } - - #[test] - fn import_key_rsa_pkcs8_putty() { - let private_key = include_str!("./test_keys/rsa_putty_pkcs1_unencrypted"); - let result = import_key(private_key.to_string(), "".to_string()).unwrap(); - assert_eq!(result.status, SshKeyImportStatus::UnsupportedKeyType); - } -} diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs index 3fe327948f8..5f794b49c73 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs @@ -16,7 +16,6 @@ mod platform_ssh_agent; #[cfg(any(target_os = "linux", target_os = "macos"))] mod peercred_unix_listener_stream; -pub mod importer; pub mod peerinfo; mod request_parser; diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index c40b7aed487..92f31cf5f89 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -51,22 +51,6 @@ export declare namespace sshagent { publicKey: string keyFingerprint: string } - export const enum SshKeyImportStatus { - /** ssh key was parsed correctly and will be returned in the result */ - Success = 0, - /** ssh key was parsed correctly but is encrypted and requires a password */ - PasswordRequired = 1, - /** ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect */ - WrongPassword = 2, - /** ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key */ - ParsingError = 3, - /** ssh key type is not supported (e.g. ecdsa) */ - UnsupportedKeyType = 4 - } - export interface SshKeyImportResult { - status: SshKeyImportStatus - sshKey?: SshKey - } export interface SshUiRequest { cipherId?: string isList: boolean @@ -79,7 +63,6 @@ export declare namespace sshagent { export function isRunning(agentState: SshAgentState): boolean export function setKeys(agentState: SshAgentState, newKeys: Array): void export function lock(agentState: SshAgentState): void - export function importKey(encodedKey: string, password: string): SshKeyImportResult export function clearKeys(agentState: SshAgentState): void export class SshAgentState { } } diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 7d20bd50699..d0c859d427c 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -182,67 +182,6 @@ pub mod sshagent { pub key_fingerprint: String, } - impl From for SshKey { - fn from(key: desktop_core::ssh_agent::importer::SshKey) -> Self { - SshKey { - private_key: key.private_key, - public_key: key.public_key, - key_fingerprint: key.key_fingerprint, - } - } - } - - #[napi] - pub enum SshKeyImportStatus { - /// ssh key was parsed correctly and will be returned in the result - Success, - /// ssh key was parsed correctly but is encrypted and requires a password - PasswordRequired, - /// ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect - WrongPassword, - /// ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key - ParsingError, - /// ssh key type is not supported (e.g. ecdsa) - UnsupportedKeyType, - } - - impl From for SshKeyImportStatus { - fn from(status: desktop_core::ssh_agent::importer::SshKeyImportStatus) -> Self { - match status { - desktop_core::ssh_agent::importer::SshKeyImportStatus::Success => { - SshKeyImportStatus::Success - } - desktop_core::ssh_agent::importer::SshKeyImportStatus::PasswordRequired => { - SshKeyImportStatus::PasswordRequired - } - desktop_core::ssh_agent::importer::SshKeyImportStatus::WrongPassword => { - SshKeyImportStatus::WrongPassword - } - desktop_core::ssh_agent::importer::SshKeyImportStatus::ParsingError => { - SshKeyImportStatus::ParsingError - } - desktop_core::ssh_agent::importer::SshKeyImportStatus::UnsupportedKeyType => { - SshKeyImportStatus::UnsupportedKeyType - } - } - } - } - - #[napi(object)] - pub struct SshKeyImportResult { - pub status: SshKeyImportStatus, - pub ssh_key: Option, - } - - impl From for SshKeyImportResult { - fn from(result: desktop_core::ssh_agent::importer::SshKeyImportResult) -> Self { - SshKeyImportResult { - status: result.status.into(), - ssh_key: result.ssh_key.map(|k| k.into()), - } - } - } - #[napi(object)] pub struct SshUIRequest { pub cipher_id: Option, @@ -359,13 +298,6 @@ pub mod sshagent { .map_err(|e| napi::Error::from_reason(e.to_string())) } - #[napi] - pub fn import_key(encoded_key: String, password: String) -> napi::Result { - let result = desktop_core::ssh_agent::importer::import_key(encoded_key, password) - .map_err(|e| napi::Error::from_reason(e.to_string()))?; - Ok(result.into()) - } - #[napi] pub fn clear_keys(agent_state: &mut SshAgentState) -> napi::Result<()> { let bitwarden_agent_state = &mut agent_state.state; diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index edd07097b54..5f8b9762594 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -102,6 +102,7 @@ import { BiometricsService, } from "@bitwarden/key-management"; import { LockComponentService } from "@bitwarden/key-management-ui"; +import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault"; import { DesktopLoginApprovalComponentService } from "../../auth/login/desktop-login-approval-component.service"; import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service"; @@ -430,6 +431,11 @@ const safeProviders: SafeProvider[] = [ useClass: DesktopLoginApprovalComponentService, deps: [I18nServiceAbstraction], }), + safeProvider({ + provide: SshImportPromptService, + useClass: DefaultSshImportPromptService, + deps: [DialogService, ToastService, PlatformUtilsServiceAbstraction, I18nServiceAbstraction], + }), ]; @NgModule({ diff --git a/apps/desktop/src/autofill/main/main-ssh-agent.service.ts b/apps/desktop/src/autofill/main/main-ssh-agent.service.ts index af79d9d7316..595ef778bcf 100644 --- a/apps/desktop/src/autofill/main/main-ssh-agent.service.ts +++ b/apps/desktop/src/autofill/main/main-ssh-agent.service.ts @@ -25,16 +25,6 @@ export class MainSshAgentService { private logService: LogService, private messagingService: MessagingService, ) { - ipcMain.handle( - "sshagent.importkey", - async ( - event: any, - { privateKey, password }: { privateKey: string; password?: string }, - ): Promise => { - return sshagent.importKey(privateKey, password); - }, - ); - ipcMain.handle("sshagent.init", async (event: any, message: any) => { this.init(); }); diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index b485b471ccb..7739ab84577 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -3532,9 +3532,6 @@ "unknownApplication": { "message": "An application" }, - "sshKeyPasswordUnsupported": { - "message": "Importing password protected SSH keys is not yet supported" - }, "invalidSshKey": { "message": "The SSH key is invalid" }, @@ -3544,7 +3541,7 @@ "importSshKeyFromClipboard": { "message": "Import key from clipboard" }, - "sshKeyPasted": { + "sshKeyImported": { "message": "SSH key imported successfully" }, "fileSavedToDevice": { diff --git a/apps/desktop/src/platform/preload.ts b/apps/desktop/src/platform/preload.ts index 05dcd484def..bf81021922f 100644 --- a/apps/desktop/src/platform/preload.ts +++ b/apps/desktop/src/platform/preload.ts @@ -1,4 +1,3 @@ -import { sshagent as ssh } from "desktop_native/napi"; import { ipcRenderer } from "electron"; import { DeviceType } from "@bitwarden/common/enums"; @@ -64,13 +63,6 @@ const sshAgent = { clearKeys: async () => { return await ipcRenderer.invoke("sshagent.clearkeys"); }, - importKey: async (key: string, password: string): Promise => { - const res = await ipcRenderer.invoke("sshagent.importkey", { - privateKey: key, - password: password, - }); - return res; - }, isLoaded(): Promise { return ipcRenderer.invoke("sshagent.isloaded"); }, diff --git a/apps/desktop/src/vault/app/vault/add-edit.component.html b/apps/desktop/src/vault/app/vault/add-edit.component.html index 6244f585bae..d79e15ebe6a 100644 --- a/apps/desktop/src/vault/app/vault/add-edit.component.html +++ b/apps/desktop/src/vault/app/vault/add-edit.component.html @@ -512,6 +512,15 @@ [ngClass]="{ 'bwi-eye': !showPrivateKey, 'bwi-eye-slash': showPrivateKey }" > + +
@@ -559,16 +568,6 @@
-
- -
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 ae332c9723b..2c8b5a8321a 100644 --- a/apps/desktop/src/vault/app/vault/add-edit.component.ts +++ b/apps/desktop/src/vault/app/vault/add-edit.component.ts @@ -3,8 +3,6 @@ import { DatePipe } from "@angular/common"; import { Component, NgZone, OnChanges, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { NgForm } from "@angular/forms"; -import { sshagent as sshAgent } from "desktop_native/napi"; -import { lastValueFrom } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/vault/components/add-edit.component"; @@ -25,8 +23,7 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService, ToastService } from "@bitwarden/components"; -import { SshKeyPasswordPromptComponent } from "@bitwarden/importer-ui"; -import { PasswordRepromptService } from "@bitwarden/vault"; +import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault"; const BroadcasterSubscriptionId = "AddEditComponent"; @@ -60,6 +57,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On toastService: ToastService, cipherAuthorizationService: CipherAuthorizationService, sdkService: SdkService, + sshImportPromptService: SshImportPromptService, ) { super( cipherService, @@ -82,6 +80,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On cipherAuthorizationService, toastService, sdkService, + sshImportPromptService, ); } @@ -159,69 +158,6 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On this.cipher.revisionDate = cipher.revisionDate; } - async importSshKeyFromClipboard(password: string = "") { - const key = await this.platformUtilsService.readFromClipboard(); - const parsedKey = await ipc.platform.sshAgent.importKey(key, password); - if (parsedKey == null) { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("invalidSshKey"), - }); - return; - } - - switch (parsedKey.status) { - case sshAgent.SshKeyImportStatus.ParsingError: - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("invalidSshKey"), - }); - return; - case sshAgent.SshKeyImportStatus.UnsupportedKeyType: - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("sshKeyTypeUnsupported"), - }); - return; - case sshAgent.SshKeyImportStatus.PasswordRequired: - case sshAgent.SshKeyImportStatus.WrongPassword: - if (password !== "") { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("sshKeyWrongPassword"), - }); - } else { - password = await this.getSshKeyPassword(); - if (password === "") { - return; - } - await this.importSshKeyFromClipboard(password); - } - return; - default: - this.cipher.sshKey.privateKey = parsedKey.sshKey.privateKey; - this.cipher.sshKey.publicKey = parsedKey.sshKey.publicKey; - this.cipher.sshKey.keyFingerprint = parsedKey.sshKey.keyFingerprint; - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("sshKeyPasted"), - }); - } - } - - async getSshKeyPassword(): Promise { - const dialog = this.dialogService.open(SshKeyPasswordPromptComponent, { - ariaModal: true, - }); - - return await lastValueFrom(dialog.closed); - } - truncateString(value: string, length: number) { return value.length > length ? value.substring(0, length) + "..." : value; } diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index 2cb1a4ee923..d0e876026d2 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -96,6 +96,7 @@ import { DefaultThemeStateService, ThemeStateService, } from "@bitwarden/common/platform/theming/theme-state.service"; +import { DialogService, ToastService } from "@bitwarden/components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; import { KdfConfigService, @@ -103,6 +104,7 @@ import { BiometricsService, } from "@bitwarden/key-management"; import { LockComponentService } from "@bitwarden/key-management-ui"; +import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault"; import { flagEnabled } from "../../utils/flags"; import { PolicyListService } from "../admin-console/core/policy-list.service"; @@ -349,6 +351,11 @@ const safeProviders: SafeProvider[] = [ useClass: WebLoginDecryptionOptionsService, deps: [MessagingService, RouterService, AcceptOrganizationInviteService], }), + safeProvider({ + provide: SshImportPromptService, + useClass: DefaultSshImportPromptService, + deps: [DialogService, ToastService, PlatformUtilsService, I18nServiceAbstraction], + }), ]; @NgModule({ diff --git a/apps/web/src/app/vault/individual-vault/add-edit.component.ts b/apps/web/src/app/vault/individual-vault/add-edit.component.ts index 3df2b9a83c9..68fcee367f1 100644 --- a/apps/web/src/app/vault/individual-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/individual-vault/add-edit.component.ts @@ -30,7 +30,7 @@ import { Launchable } from "@bitwarden/common/vault/interfaces/launchable"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; -import { PasswordRepromptService } from "@bitwarden/vault"; +import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault"; @Component({ selector: "app-vault-add-edit", @@ -76,6 +76,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On cipherAuthorizationService: CipherAuthorizationService, toastService: ToastService, sdkService: SdkService, + sshImportPromptService: SshImportPromptService, ) { super( cipherService, @@ -98,6 +99,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On cipherAuthorizationService, toastService, sdkService, + sshImportPromptService, ); } diff --git a/apps/web/src/app/vault/org-vault/add-edit.component.ts b/apps/web/src/app/vault/org-vault/add-edit.component.ts index 8490ec6c9db..89f3b79f1fb 100644 --- a/apps/web/src/app/vault/org-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/org-vault/add-edit.component.ts @@ -28,7 +28,7 @@ import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy"; -import { PasswordRepromptService } from "@bitwarden/vault"; +import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault"; import { AddEditComponent as BaseAddEditComponent } from "../individual-vault/add-edit.component"; @@ -64,6 +64,7 @@ export class AddEditComponent extends BaseAddEditComponent { cipherAuthorizationService: CipherAuthorizationService, toastService: ToastService, sdkService: SdkService, + sshImportPromptService: SshImportPromptService, ) { super( cipherService, @@ -88,6 +89,7 @@ export class AddEditComponent extends BaseAddEditComponent { cipherAuthorizationService, toastService, sdkService, + sshImportPromptService, ); } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 229cca65ae5..3888b42fe76 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -38,7 +38,7 @@ "restoreMembers": { "message": "Restore members" }, - "cannotRestoreAccessError":{ + "cannotRestoreAccessError": { "message": "Cannot restore organization access" }, "allApplicationsWithCount": { @@ -1355,8 +1355,8 @@ "yourAccountIsLocked": { "message": "Your account is locked" }, - "uuid":{ - "message" : "UUID" + "uuid": { + "message": "UUID" }, "unlock": { "message": "Unlock" @@ -5904,10 +5904,10 @@ "bulkFilteredMessage": { "message": "Excluded, not applicable for this action" }, - "nonCompliantMembersTitle":{ + "nonCompliantMembersTitle": { "message": "Non-compliant members" }, - "nonCompliantMembersError":{ + "nonCompliantMembersError": { "message": "Members that are non-compliant with the Single organization or Two-step login policy cannot be restored until they adhere to the policy requirements" }, "fingerprint": { @@ -9330,7 +9330,7 @@ "message": "for Bitwarden using the implementation guide for your Identity Provider.", "description": "This represents the end of a sentence, broken up to include links. The full sentence will be 'Configure single sign-on for Bitwarden using the implementation guide for your Identity Provider." }, - "userProvisioning":{ + "userProvisioning": { "message": "User provisioning" }, "scimIntegration": { @@ -9344,22 +9344,22 @@ "message": "(System for Cross-domain Identity Management) to automatically provision users and groups to Bitwarden using the implementation guide for your Identity Provider.", "description": "This represents the end of a sentence, broken up to include links. The full sentence will be 'Configure SCIM (System for Cross-domain Identity Management) to automatically provision users and groups to Bitwarden using the implementation guide for your Identity Provider" }, - "bwdc":{ + "bwdc": { "message": "Bitwarden Directory Connector" }, "bwdcDesc": { "message": "Configure Bitwarden Directory Connector to automatically provision users and groups using the implementation guide for your Identity Provider." }, - "eventManagement":{ + "eventManagement": { "message": "Event management" }, - "eventManagementDesc":{ + "eventManagementDesc": { "message": "Integrate Bitwarden event logs with your SIEM (system information and event management) system by using the implementation guide for your platform." }, - "deviceManagement":{ + "deviceManagement": { "message": "Device management" }, - "deviceManagementDesc":{ + "deviceManagementDesc": { "message": "Configure device management for Bitwarden using the implementation guide for your platform." }, "desktopRequired": { @@ -9368,7 +9368,7 @@ "reopenLinkOnDesktop": { "message": "Reopen this link from your email on a desktop." }, - "integrationCardTooltip":{ + "integrationCardTooltip": { "message": "Launch $INTEGRATION$ implementation guide.", "placeholders": { "integration": { @@ -9377,7 +9377,7 @@ } } }, - "smIntegrationTooltip":{ + "smIntegrationTooltip": { "message": "Set up $INTEGRATION$.", "placeholders": { "integration": { @@ -9386,7 +9386,7 @@ } } }, - "smSdkTooltip":{ + "smSdkTooltip": { "message": "View $SDK$ repository", "placeholders": { "sdk": { @@ -9395,7 +9395,7 @@ } } }, - "integrationCardAriaLabel":{ + "integrationCardAriaLabel": { "message": "open $INTEGRATION$ implementation guide in a new tab.", "placeholders": { "integration": { @@ -9404,7 +9404,7 @@ } } }, - "smSdkAriaLabel":{ + "smSdkAriaLabel": { "message": "view $SDK$ repository in a new tab.", "placeholders": { "sdk": { @@ -9413,7 +9413,7 @@ } } }, - "smIntegrationCardAriaLabel":{ + "smIntegrationCardAriaLabel": { "message": "set up $INTEGRATION$ implementation guide in a new tab.", "placeholders": { "integration": { @@ -9820,7 +9820,7 @@ "message": "Config" }, "learnMoreAboutEmergencyAccess": { - "message":"Learn more about emergency access" + "message": "Learn more about emergency access" }, "learnMoreAboutMatchDetection": { "message": "Learn more about match detection" @@ -10122,7 +10122,7 @@ "selfHostingTitleProper": { "message": "Self-Hosting" }, - "claim-domain-single-org-warning" : { + "claim-domain-single-org-warning": { "message": "Claiming a domain will turn on the single organization policy." }, "single-org-revoked-user-warning": { @@ -10363,6 +10363,36 @@ "organizationNameMaxLength": { "message": "Organization name cannot exceed 50 characters." }, + "sshKeyWrongPassword": { + "message": "The password you entered is incorrect." + }, + "importSshKey": { + "message": "Import" + }, + "confirmSshKeyPassword": { + "message": "Confirm password" + }, + "enterSshKeyPasswordDesc": { + "message": "Enter the password for the SSH key." + }, + "enterSshKeyPassword": { + "message": "Enter password" + }, + "invalidSshKey": { + "message": "The SSH key is invalid" + }, + "sshKeyTypeUnsupported": { + "message": "The SSH key type is not supported" + }, + "importSshKeyFromClipboard": { + "message": "Import key from clipboard" + }, + "sshKeyImported": { + "message": "SSH key imported successfully" + }, + "copySSHPrivateKey": { + "message": "Copy private key" + }, "openingExtension": { "message": "Opening the Bitwarden browser extension" }, diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index c309aa9624a..c843d186625 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -41,7 +41,7 @@ import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService, ToastService } from "@bitwarden/components"; import { generate_ssh_key } from "@bitwarden/sdk-internal"; -import { PasswordRepromptService } from "@bitwarden/vault"; +import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault"; @Directive() export class AddEditComponent implements OnInit, OnDestroy { @@ -131,7 +131,8 @@ export class AddEditComponent implements OnInit, OnDestroy { protected configService: ConfigService, protected cipherAuthorizationService: CipherAuthorizationService, protected toastService: ToastService, - private sdkService: SdkService, + protected sdkService: SdkService, + private sshImportPromptService: SshImportPromptService, ) { this.typeOptions = [ { name: i18nService.t("typeLogin"), value: CipherType.Login }, @@ -824,6 +825,15 @@ export class AddEditComponent implements OnInit, OnDestroy { return true; } + async importSshKeyFromClipboard() { + const key = await this.sshImportPromptService.importSshKeyFromClipboard(); + if (key != null) { + this.cipher.sshKey.privateKey = key.privateKey; + this.cipher.sshKey.publicKey = key.publicKey; + this.cipher.sshKey.keyFingerprint = key.keyFingerprint; + } + } + private async generateSshKey(showNotification: boolean = true) { await firstValueFrom(this.sdkService.client$); const sshKey = generate_ssh_key("Ed25519"); diff --git a/libs/angular/tsconfig.json b/libs/angular/tsconfig.json index c603e5cf170..d77e56d778e 100644 --- a/libs/angular/tsconfig.json +++ b/libs/angular/tsconfig.json @@ -13,6 +13,8 @@ "@bitwarden/generator-history": ["../tools/generator/extensions/history/src"], "@bitwarden/generator-legacy": ["../tools/generator/extensions/legacy/src"], "@bitwarden/generator-navigation": ["../tools/generator/extensions/navigation/src"], + "@bitwarden/importer/core": ["../importer/src"], + "@bitwarden/importer-ui": ["../importer/src/components"], "@bitwarden/key-management": ["../key-management/src"], "@bitwarden/platform": ["../platform/src"], "@bitwarden/ui-common": ["../ui/common/src"], diff --git a/libs/common/src/models/export/cipher.export.ts b/libs/common/src/models/export/cipher.export.ts index e542d0dfc1f..7d0ef9e9c34 100644 --- a/libs/common/src/models/export/cipher.export.ts +++ b/libs/common/src/models/export/cipher.export.ts @@ -73,6 +73,7 @@ export class CipherExport { break; case CipherType.SshKey: view.sshKey = SshKeyExport.toView(req.sshKey); + break; } if (req.passwordHistory != null) { diff --git a/libs/common/src/models/export/ssh-key.export.ts b/libs/common/src/models/export/ssh-key.export.ts index a99ebac34b3..5387daf7dd0 100644 --- a/libs/common/src/models/export/ssh-key.export.ts +++ b/libs/common/src/models/export/ssh-key.export.ts @@ -1,5 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { import_ssh_key } from "@bitwarden/sdk-internal"; import { EncString } from "../../platform/models/domain/enc-string"; import { SshKey as SshKeyDomain } from "../../vault/models/domain/ssh-key"; @@ -17,16 +18,18 @@ export class SshKeyExport { } static toView(req: SshKeyExport, view = new SshKeyView()) { - view.privateKey = req.privateKey; - view.publicKey = req.publicKey; - view.keyFingerprint = req.keyFingerprint; + const parsedKey = import_ssh_key(req.privateKey); + view.privateKey = parsedKey.privateKey; + view.publicKey = parsedKey.publicKey; + view.keyFingerprint = parsedKey.fingerprint; return view; } static toDomain(req: SshKeyExport, domain = new SshKeyDomain()) { - domain.privateKey = req.privateKey != null ? new EncString(req.privateKey) : null; - domain.publicKey = req.publicKey != null ? new EncString(req.publicKey) : null; - domain.keyFingerprint = req.keyFingerprint != null ? new EncString(req.keyFingerprint) : null; + const parsedKey = import_ssh_key(req.privateKey); + domain.privateKey = new EncString(parsedKey.privateKey); + domain.publicKey = new EncString(parsedKey.publicKey); + domain.keyFingerprint = new EncString(parsedKey.fingerprint); return domain; } diff --git a/libs/common/tsconfig.json b/libs/common/tsconfig.json index 2d1379f9c5f..dacc7d65ea5 100644 --- a/libs/common/tsconfig.json +++ b/libs/common/tsconfig.json @@ -9,6 +9,7 @@ // TODO: Remove once billing stops depending on components "@bitwarden/components": ["../components/src"], "@bitwarden/key-management": ["../key-management/src"], + "@bitwarden/vault-export-core": ["../tools/export/vault-export/vault-export-core/src"], "@bitwarden/platform": ["../platform/src"], // TODO: Remove once billing stops depending on components "@bitwarden/ui-common": ["../ui/common/src"] diff --git a/libs/importer/jest.config.js b/libs/importer/jest.config.js index ab449dc7757..ee5ae302b99 100644 --- a/libs/importer/jest.config.js +++ b/libs/importer/jest.config.js @@ -7,7 +7,7 @@ const sharedConfig = require("../shared/jest.config.ts"); /** @type {import('jest').Config} */ module.exports = { ...sharedConfig, - preset: "ts-jest", + preset: "jest-preset-angular", testEnvironment: "jsdom", moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, { prefix: "/", diff --git a/libs/importer/src/components/import.component.ts b/libs/importer/src/components/import.component.ts index 6ea58545352..79d194b87bc 100644 --- a/libs/importer/src/components/import.component.ts +++ b/libs/importer/src/components/import.component.ts @@ -37,6 +37,7 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract 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"; +import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -96,6 +97,7 @@ const safeProviders: SafeProvider[] = [ EncryptService, PinServiceAbstraction, AccountService, + SdkService, ], }), ]; diff --git a/libs/importer/src/services/import.service.spec.ts b/libs/importer/src/services/import.service.spec.ts index 4de0bf70b07..908f062ecc1 100644 --- a/libs/importer/src/services/import.service.spec.ts +++ b/libs/importer/src/services/import.service.spec.ts @@ -1,16 +1,19 @@ import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { KeyService } from "@bitwarden/key-management"; +import { BitwardenClient } from "@bitwarden/sdk-internal"; import { BitwardenPasswordProtectedImporter } from "../importers/bitwarden/bitwarden-password-protected-importer"; import { Importer } from "../importers/importer"; @@ -30,6 +33,7 @@ describe("ImportService", () => { let encryptService: MockProxy; let pinService: MockProxy; let accountService: MockProxy; + let sdkService: MockProxy; beforeEach(() => { cipherService = mock(); @@ -40,6 +44,9 @@ describe("ImportService", () => { keyService = mock(); encryptService = mock(); pinService = mock(); + const mockClient = mock(); + sdkService = mock(); + sdkService.client$ = of(mockClient, mockClient, mockClient); importService = new ImportService( cipherService, @@ -51,6 +58,7 @@ describe("ImportService", () => { encryptService, pinService, accountService, + sdkService, ); }); diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index 781b4f75e56..cc9cdc39320 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -15,6 +15,7 @@ import { ImportOrganizationCiphersRequest } from "@bitwarden/common/models/reque import { KvpRequest } from "@bitwarden/common/models/request/kvp.request"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -114,6 +115,7 @@ export class ImportService implements ImportServiceAbstraction { private encryptService: EncryptService, private pinService: PinServiceAbstraction, private accountService: AccountService, + private sdkService: SdkService, ) {} getImportOptions(): ImportOption[] { diff --git a/libs/vault/src/cipher-form/cipher-form.stories.ts b/libs/vault/src/cipher-form/cipher-form.stories.ts index fdcfa200321..5d6464b4c79 100644 --- a/libs/vault/src/cipher-form/cipher-form.stories.ts +++ b/libs/vault/src/cipher-form/cipher-form.stories.ts @@ -24,6 +24,7 @@ import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherType } from "@bitwarden/common/vault/enums"; +import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; @@ -39,6 +40,8 @@ import { // eslint-disable-next-line no-restricted-imports import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/src/app/core/tests"; +import { SshImportPromptService } from "../services/ssh-import-prompt.service"; + import { CipherFormService } from "./abstractions/cipher-form.service"; import { TotpCaptureService } from "./abstractions/totp-capture.service"; import { CipherFormModule } from "./cipher-form.module"; @@ -146,6 +149,12 @@ export default { enabled$: new BehaviorSubject(true), }, }, + { + provide: SshImportPromptService, + useValue: { + importSshKeyFromClipboard: () => Promise.resolve(new SshKeyData()), + }, + }, { provide: CipherFormGenerationService, useValue: { diff --git a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html index 51b07a1cbf3..1b11b37084b 100644 --- a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html +++ b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.html @@ -15,6 +15,14 @@ data-testid="toggle-privateKey-visibility" bitPasswordInputToggle > + diff --git a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts index 773ddd4ad66..500bb886f7a 100644 --- a/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts +++ b/libs/vault/src/cipher-form/components/sshkey-section/sshkey-section.component.ts @@ -7,7 +7,8 @@ import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ClientType } from "@bitwarden/common/enums"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view"; @@ -22,6 +23,7 @@ import { } from "@bitwarden/components"; import { generate_ssh_key } from "@bitwarden/sdk-internal"; +import { SshImportPromptService } from "../../../services/ssh-import-prompt.service"; import { CipherFormContainer } from "../../cipher-form-container"; @Component({ @@ -60,11 +62,14 @@ export class SshKeySectionComponent implements OnInit { keyFingerprint: [""], }); + showImport = false; + constructor( private cipherFormContainer: CipherFormContainer, private formBuilder: FormBuilder, - private i18nService: I18nService, private sdkService: SdkService, + private sshImportPromptService: SshImportPromptService, + private platformUtilsService: PlatformUtilsService, ) { this.cipherFormContainer.registerChildForm("sshKeyDetails", this.sshKeyForm); this.sshKeyForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => { @@ -87,6 +92,11 @@ export class SshKeySectionComponent implements OnInit { } this.sshKeyForm.disable(); + + // Web does not support clipboard access + if (this.platformUtilsService.getClientType() !== ClientType.Web) { + this.showImport = true; + } } /** Set form initial form values from the current cipher */ @@ -100,6 +110,17 @@ export class SshKeySectionComponent implements OnInit { }); } + async importSshKeyFromClipboard() { + const key = await this.sshImportPromptService.importSshKeyFromClipboard(); + if (key != null) { + this.sshKeyForm.setValue({ + privateKey: key.privateKey, + publicKey: key.publicKey, + keyFingerprint: key.keyFingerprint, + }); + } + } + private async generateSshKey() { await firstValueFrom(this.sdkService.client$); const sshKey = generate_ssh_key("Ed25519"); diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index e4857411d05..d21e430f0a3 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -25,8 +25,10 @@ export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.compon export * from "./components/carousel"; export * as VaultIcons from "./icons"; - export * from "./tasks"; +export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service"; +export { SshImportPromptService } from "./services/ssh-import-prompt.service"; + export * from "./abstractions/change-login-password.service"; export * from "./services/default-change-login-password.service"; diff --git a/libs/vault/src/services/default-ssh-import-prompt.service.ts b/libs/vault/src/services/default-ssh-import-prompt.service.ts new file mode 100644 index 00000000000..c4e51dd3638 --- /dev/null +++ b/libs/vault/src/services/default-ssh-import-prompt.service.ts @@ -0,0 +1,109 @@ +import { Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SshKeyApi } from "@bitwarden/common/vault/models/api/ssh-key.api"; +import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data"; +import { DialogService, ToastService } from "@bitwarden/components"; +import { SshKeyPasswordPromptComponent } from "@bitwarden/importer-ui"; +import { import_ssh_key, SshKeyImportError, SshKeyView } from "@bitwarden/sdk-internal"; + +import { SshImportPromptService } from "./ssh-import-prompt.service"; + +/** + * Used to import ssh keys and prompt for their password. + */ +@Injectable() +export class DefaultSshImportPromptService implements SshImportPromptService { + constructor( + private dialogService: DialogService, + private toastService: ToastService, + private platformUtilsService: PlatformUtilsService, + private i18nService: I18nService, + ) {} + + async importSshKeyFromClipboard(): Promise { + const key = await this.platformUtilsService.readFromClipboard(); + + let isPasswordProtectedSshKey = false; + + let parsedKey: SshKeyView | null = null; + + try { + parsedKey = import_ssh_key(key); + } catch (e) { + const error = e as SshKeyImportError; + if (error.variant === "PasswordRequired" || error.variant === "WrongPassword") { + isPasswordProtectedSshKey = true; + } else { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t(this.sshImportErrorVariantToI18nKey(error.variant)), + }); + return null; + } + } + + if (isPasswordProtectedSshKey) { + for (;;) { + const password = await this.getSshKeyPassword(); + if (password === "" || password == null) { + return null; + } + + try { + parsedKey = import_ssh_key(key, password); + break; + } catch (e) { + const error = e as SshKeyImportError; + if (error.variant !== "WrongPassword") { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t(this.sshImportErrorVariantToI18nKey(error.variant)), + }); + return null; + } + } + } + } + + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("sshKeyImported"), + }); + + return new SshKeyData( + new SshKeyApi({ + privateKey: parsedKey!.privateKey, + publicKey: parsedKey!.publicKey, + keyFingerprint: parsedKey!.fingerprint, + }), + ); + } + + private sshImportErrorVariantToI18nKey(variant: string): string { + switch (variant) { + case "ParsingError": + return "invalidSshKey"; + case "UnsupportedKeyType": + return "sshKeyTypeUnsupported"; + case "PasswordRequired": + case "WrongPassword": + return "sshKeyWrongPassword"; + default: + return "errorOccurred"; + } + } + + private async getSshKeyPassword(): Promise { + const dialog = this.dialogService.open(SshKeyPasswordPromptComponent, { + ariaModal: true, + }); + + return await firstValueFrom(dialog.closed); + } +} diff --git a/libs/vault/src/services/ssh-import-prompt.service.spec.ts b/libs/vault/src/services/ssh-import-prompt.service.spec.ts new file mode 100644 index 00000000000..49b2b898d7a --- /dev/null +++ b/libs/vault/src/services/ssh-import-prompt.service.spec.ts @@ -0,0 +1,111 @@ +import { MockProxy, mock } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SshKeyApi } from "@bitwarden/common/vault/models/api/ssh-key.api"; +import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data"; +import { DialogService, ToastService } from "@bitwarden/components"; +import * as sdkInternal from "@bitwarden/sdk-internal"; + +import { DefaultSshImportPromptService } from "./default-ssh-import-prompt.service"; + +jest.mock("@bitwarden/sdk-internal"); + +const exampleSshKey = { + privateKey: "private_key", + publicKey: "public_key", + fingerprint: "key_fingerprint", +} as sdkInternal.SshKeyView; + +const exampleSshKeyData = new SshKeyData( + new SshKeyApi({ + publicKey: exampleSshKey.publicKey, + privateKey: exampleSshKey.privateKey, + keyFingerprint: exampleSshKey.fingerprint, + }), +); + +describe("SshImportPromptService", () => { + let sshImportPromptService: DefaultSshImportPromptService; + + let dialogService: MockProxy; + let toastService: MockProxy; + let platformUtilsService: MockProxy; + let i18nService: MockProxy; + + beforeEach(() => { + dialogService = mock(); + toastService = mock(); + platformUtilsService = mock(); + i18nService = mock(); + + sshImportPromptService = new DefaultSshImportPromptService( + dialogService, + toastService, + platformUtilsService, + i18nService, + ); + jest.clearAllMocks(); + }); + + describe("importSshKeyFromClipboard()", () => { + it("imports unencrypted ssh key", async () => { + jest.spyOn(sdkInternal, "import_ssh_key").mockReturnValue(exampleSshKey); + platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key"); + expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(exampleSshKeyData); + }); + + it("requests password for encrypted ssh key", async () => { + jest + .spyOn(sdkInternal, "import_ssh_key") + .mockImplementationOnce(() => { + throw { variant: "PasswordRequired" }; + }) + .mockImplementationOnce(() => exampleSshKey); + dialogService.open.mockReturnValue({ closed: new BehaviorSubject("password") } as any); + platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key"); + + expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(exampleSshKeyData); + expect(dialogService.open).toHaveBeenCalled(); + }); + + it("cancels when no password was provided", async () => { + jest.spyOn(sdkInternal, "import_ssh_key").mockImplementationOnce(() => { + throw { variant: "PasswordRequired" }; + }); + dialogService.open.mockReturnValue({ closed: new BehaviorSubject("") } as any); + platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key"); + + expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(null); + expect(dialogService.open).toHaveBeenCalled(); + }); + + it("passes through error on no password", async () => { + jest.spyOn(sdkInternal, "import_ssh_key").mockImplementationOnce(() => { + throw { variant: "UnsupportedKeyType" }; + }); + platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key"); + + expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(null); + expect(i18nService.t).toHaveBeenCalledWith("sshKeyTypeUnsupported"); + }); + + it("passes through error with password", async () => { + jest + .spyOn(sdkInternal, "import_ssh_key") + .mockClear() + .mockImplementationOnce(() => { + throw { variant: "PasswordRequired" }; + }) + .mockImplementationOnce(() => { + throw { variant: "UnsupportedKeyType" }; + }); + platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key"); + dialogService.open.mockReturnValue({ closed: new BehaviorSubject("password") } as any); + + expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(null); + expect(i18nService.t).toHaveBeenCalledWith("sshKeyTypeUnsupported"); + }); + }); +}); diff --git a/libs/vault/src/services/ssh-import-prompt.service.ts b/libs/vault/src/services/ssh-import-prompt.service.ts new file mode 100644 index 00000000000..aae5159895b --- /dev/null +++ b/libs/vault/src/services/ssh-import-prompt.service.ts @@ -0,0 +1,5 @@ +import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data"; + +export abstract class SshImportPromptService { + abstract importSshKeyFromClipboard: () => Promise; +} diff --git a/libs/vault/tsconfig.json b/libs/vault/tsconfig.json index e1515183f22..6039dccd811 100644 --- a/libs/vault/tsconfig.json +++ b/libs/vault/tsconfig.json @@ -8,11 +8,13 @@ "@bitwarden/auth/common": ["../auth/src/common"], "@bitwarden/common/*": ["../common/src/*"], "@bitwarden/components": ["../components/src"], + "@bitwarden/importer-ui": ["../importer/src/components"], "@bitwarden/generator-components": ["../tools/generator/components/src"], "@bitwarden/generator-core": ["../tools/generator/core/src"], "@bitwarden/generator-history": ["../tools/generator/extensions/history/src"], "@bitwarden/generator-legacy": ["../tools/generator/extensions/legacy/src"], "@bitwarden/generator-navigation": ["../tools/generator/extensions/navigation/src"], + "@bitwarden/vault-export-core": ["../tools/export/vault-export/vault-export-core/src"], "@bitwarden/key-management": ["../key-management/src"], "@bitwarden/platform": ["../platform/src"], "@bitwarden/ui-common": ["../ui/common/src"], From e0b77c97bab8124bec6635cdb21d4dd7b7d4d116 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 10 Mar 2025 10:59:07 -0700 Subject: [PATCH 03/35] [PM-18959] - retain popup view cache on cipher view or edit (#13742) * clear popup view cache on tab navigation but not on view or edit cipher * revert clearing cache on tab change * clean up function --- .../popup/view-cache/popup-view-cache.service.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts b/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts index 2c29f1e5763..457198eaa4e 100644 --- a/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts +++ b/apps/browser/src/platform/popup/view-cache/popup-view-cache.service.ts @@ -64,8 +64,16 @@ export class PopupViewCacheService implements ViewCacheService { filter((e) => e instanceof NavigationEnd), /** Skip the first navigation triggered by `popupRouterCacheGuard` */ skip(1), + filter((e: NavigationEnd) => + // viewing/editing a cipher and navigating back to the vault list should not clear the cache + ["/view-cipher", "/edit-cipher", "/tabs/vault"].every( + (route) => !e.urlAfterRedirects.startsWith(route), + ), + ), ) - .subscribe(() => this.clearState()); + .subscribe((e) => { + return this.clearState(); + }); } /** From beccf1a9d7fa43a9bb74389ee426c6669e85b753 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 10 Mar 2025 10:59:16 -0700 Subject: [PATCH 04/35] increase size of password history dialog (#13693) --- .../app/vault/individual-vault/password-history.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/vault/individual-vault/password-history.component.html b/apps/web/src/app/vault/individual-vault/password-history.component.html index 4eaca8f736e..ccbe20c5192 100644 --- a/apps/web/src/app/vault/individual-vault/password-history.component.html +++ b/apps/web/src/app/vault/individual-vault/password-history.component.html @@ -1,4 +1,4 @@ - + {{ "passwordHistory" | i18n }} From a30a6ee7fb59718aa8bf9039e45f04f147a3842f Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 10 Mar 2025 11:04:36 -0700 Subject: [PATCH 05/35] remove margin on autofill when no items present (#13691) --- .../autofill-vault-list-items.component.html | 1 + .../vault-list-items-container.component.html | 7 ++++++- .../vault-list-items-container.component.ts | 6 ++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html index 071873b40c9..40f00ab4332 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-vault-list-items/autofill-vault-list-items.component.html @@ -6,6 +6,7 @@ (onRefresh)="refreshCurrentTab()" [description]="(showEmptyAutofillTip$ | async) ? ('autofillSuggestionsTip' | i18n) : null" showAutofillButton + [disableDescriptionMargin]="showEmptyAutofillTip$ | async" [primaryActionAutofill]="clickItemsToAutofillVaultView" [groupByType]="groupByType()" > diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html index 2272d3fbd6c..cbce4bf2961 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.html @@ -70,7 +70,12 @@ -
+
{{ description }}
diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts index cb758e7a48d..9d70c0ba236 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts @@ -245,6 +245,12 @@ export class VaultListItemsContainerComponent implements OnInit, AfterViewInit { @Input({ transform: booleanAttribute }) disableSectionMargin: boolean = false; + /** + * Remove the description margin + */ + @Input({ transform: booleanAttribute }) + disableDescriptionMargin: boolean = false; + /** * The tooltip text for the organization icon for ciphers that belong to an organization. * @param cipher From cda1cdb1090d0ec88dc6e1f608cce3cafab69be3 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 10 Mar 2025 11:04:53 -0700 Subject: [PATCH 06/35] [PM-12726] - [Defect] [Web] "Secure Note" shows in item filters instead of "Note" (#13707) * use note instead of secure note * allow item history to be selectable * Revert "allow item history to be selectable" This reverts commit 7144a210b5707a3ef6db4ac162cd4ef1273ed8fa. --- .../vault-filter/components/vault-filter.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index 9c002a4ed94..a232f8faec5 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -243,7 +243,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { }, { id: "note", - name: this.i18nService.t("typeSecureNote"), + name: this.i18nService.t("note"), type: CipherType.SecureNote, icon: "bwi-sticky-note", }, From 985942ac058de35d2bf5e939cc80a203177c1f0f Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 10 Mar 2025 11:07:04 -0700 Subject: [PATCH 07/35] collapse collections initially (#13646) --- .../components/collection-filter.component.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/libs/angular/src/vault/vault-filter/components/collection-filter.component.ts b/libs/angular/src/vault/vault-filter/components/collection-filter.component.ts index d104026f2f6..168afbdd72a 100644 --- a/libs/angular/src/vault/vault-filter/components/collection-filter.component.ts +++ b/libs/angular/src/vault/vault-filter/components/collection-filter.component.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Directive, EventEmitter, Input, Output } from "@angular/core"; +import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; import { CollectionView } from "@bitwarden/admin-console/common"; import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node"; @@ -10,7 +10,7 @@ import { TopLevelTreeNode } from "../models/top-level-tree-node.model"; import { VaultFilter } from "../models/vault-filter.model"; @Directive() -export class CollectionFilterComponent { +export class CollectionFilterComponent implements OnInit { @Input() hide = false; @Input() collapsedFilterNodes: Set; @Input() collectionNodes: DynamicTreeNode; @@ -51,4 +51,13 @@ export class CollectionFilterComponent { async toggleCollapse(node: ITreeNodeObject) { this.onNodeCollapseStateChange.emit(node); } + + ngOnInit() { + // Populate the set with all node IDs so all nodes are collapsed initially. + if (this.collectionNodes?.fullList) { + this.collectionNodes.fullList.forEach((node) => { + this.collapsedFilterNodes.add(node.id); + }); + } + } } From a19bf1687e9c57375bc60e40c6b04b4ea495e4a8 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 10 Mar 2025 11:07:22 -0700 Subject: [PATCH 08/35] [PM-12557] - center align custom field buttons (#13670) * center align custom field buttons * add margin --- .../components/custom-fields/custom-fields.component.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html index fab3c8f1ab1..c7c5f4a930e 100644 --- a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html +++ b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html @@ -87,7 +87,7 @@ (click)="openAddEditCustomFieldDialog({ index: i, label: field.value.name })" [appA11yTitle]="'editFieldLabel' | i18n: field.value.name" bitIconButton="bwi-pencil-square" - class="tw-self-end" + class="tw-self-center tw-mt-2" data-testid="edit-custom-field-button" *ngIf="!isPartialEdit" > @@ -95,7 +95,7 @@ diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts index f89a72b5d2b..0f949e17146 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts @@ -31,7 +31,7 @@ export type VaultFilterSection = { }; action: (filterNode: TreeNode) => Promise; edit?: { - text: string; + filterName: string; action: (filter: VaultFilterType) => void; }; add?: { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 3888b42fe76..1948f589661 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -491,6 +491,19 @@ "editFolder": { "message": "Edit folder" }, + "editWithName": { + "message": "Edit $ITEM$: $NAME$", + "placeholders": { + "item": { + "content": "$1", + "example": "login" + }, + "name": { + "content": "$2", + "example": "Social" + } + } + }, "newFolder": { "message": "New folder" }, From d943f53477613dc4572df981e23b22a99dd38266 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Mon, 10 Mar 2025 11:12:02 -0700 Subject: [PATCH 10/35] refactor(routing): [Auth/PM-18783] Remove Unauth UI route swapping for all components except 2FA (#13645) Removes `unauthUiRefreshSwap()` from all routing modules for all refreshed components except for 2FA. This does not remove the legacy components themselves, just the routing to them. --------- Co-authored-by: Todd Martin --- .../src/auth/popup/environment.component.html | 2 +- .../auth/popup/set-password.component.html | 2 +- .../popup/utils/auth-popout-window.spec.ts | 2 +- .../auth/popup/utils/auth-popout-window.ts | 2 +- apps/browser/src/popup/app-routing.module.ts | 330 +++++++---------- apps/browser/src/popup/app.component.ts | 4 +- apps/desktop/src/app/app-routing.module.ts | 269 ++++++-------- apps/web/src/app/oss-routing.module.ts | 333 ++++++------------ .../auth/src/angular/login/login.component.ts | 32 +- 9 files changed, 343 insertions(+), 633 deletions(-) diff --git a/apps/browser/src/auth/popup/environment.component.html b/apps/browser/src/auth/popup/environment.component.html index ff19739548a..21e69fbbc39 100644 --- a/apps/browser/src/auth/popup/environment.component.html +++ b/apps/browser/src/auth/popup/environment.component.html @@ -1,7 +1,7 @@
- +

{{ "appName" | i18n }} diff --git a/apps/browser/src/auth/popup/set-password.component.html b/apps/browser/src/auth/popup/set-password.component.html index 6261608c345..71a2e3ac588 100644 --- a/apps/browser/src/auth/popup/set-password.component.html +++ b/apps/browser/src/auth/popup/set-password.component.html @@ -1,7 +1,7 @@
- +

{{ "setMasterPassword" | i18n }} diff --git a/apps/browser/src/auth/popup/utils/auth-popout-window.spec.ts b/apps/browser/src/auth/popup/utils/auth-popout-window.spec.ts index 0b47fa4287e..b2c20ba2849 100644 --- a/apps/browser/src/auth/popup/utils/auth-popout-window.spec.ts +++ b/apps/browser/src/auth/popup/utils/auth-popout-window.spec.ts @@ -72,7 +72,7 @@ describe("AuthPopoutWindow", () => { it("closes any existing popup window types that are open to the login extension route", async () => { const loginTab = createChromeTabMock({ - url: chrome.runtime.getURL("popup/index.html#/home"), + url: chrome.runtime.getURL("popup/index.html#/login"), }); jest.spyOn(BrowserApi, "tabsQuery").mockResolvedValue([loginTab]); jest.spyOn(BrowserApi, "removeWindow"); diff --git a/apps/browser/src/auth/popup/utils/auth-popout-window.ts b/apps/browser/src/auth/popup/utils/auth-popout-window.ts index 2f135038315..0646b684b22 100644 --- a/apps/browser/src/auth/popup/utils/auth-popout-window.ts +++ b/apps/browser/src/auth/popup/utils/auth-popout-window.ts @@ -13,7 +13,7 @@ const AuthPopoutType = { const extensionUnlockUrls = new Set([ chrome.runtime.getURL("popup/index.html#/lock"), - chrome.runtime.getURL("popup/index.html#/home"), + chrome.runtime.getURL("popup/index.html#/login"), ]); /** diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 8f5d754b554..b33940a68d2 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -7,7 +7,6 @@ import { EnvironmentSelectorRouteData, ExtensionDefaultOverlayPosition, } from "@bitwarden/angular/auth/components/environment-selector.component"; -import { unauthUiRefreshRedirect } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-redirect"; import { unauthUiRefreshSwap } from "@bitwarden/angular/auth/functions/unauth-ui-refresh-route-swap"; import { activeAuthGuard, @@ -58,15 +57,9 @@ import { ExtensionAnonLayoutWrapperComponent, ExtensionAnonLayoutWrapperData, } from "../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper.component"; -import { HintComponent } from "../auth/popup/hint.component"; -import { HomeComponent } from "../auth/popup/home.component"; -import { LoginDecryptionOptionsComponentV1 } from "../auth/popup/login-decryption-options/login-decryption-options-v1.component"; -import { LoginComponentV1 } from "../auth/popup/login-v1.component"; -import { LoginViaAuthRequestComponentV1 } from "../auth/popup/login-via-auth-request-v1.component"; import { RemovePasswordComponent } from "../auth/popup/remove-password.component"; import { SetPasswordComponent } from "../auth/popup/set-password.component"; import { AccountSecurityComponent } from "../auth/popup/settings/account-security.component"; -import { SsoComponentV1 } from "../auth/popup/sso-v1.component"; import { TwoFactorOptionsComponentV1 } from "../auth/popup/two-factor-options-v1.component"; import { TwoFactorComponentV1 } from "../auth/popup/two-factor-v1.component"; import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component"; @@ -131,20 +124,19 @@ const routes: Routes = [ children: [], // Children lets us have an empty component. canActivate: [ popupRouterCacheGuard, - redirectGuard({ loggedIn: "/tabs/current", loggedOut: "/home", locked: "/lock" }), + redirectGuard({ loggedIn: "/tabs/current", loggedOut: "/login", locked: "/lock" }), ], }, + { + path: "home", + redirectTo: "login", + pathMatch: "full", + }, { path: "vault", redirectTo: "/tabs/vault", pathMatch: "full", }, - { - path: "home", - component: HomeComponent, - canActivate: [unauthGuardFn(unauthRouteOverrides), unauthUiRefreshRedirect("/login")], - data: { elevation: 1 } satisfies RouteDataProperties, - }, { path: "fido2", component: Fido2Component, @@ -206,40 +198,6 @@ const routes: Routes = [ canActivate: [unauthGuardFn(unauthRouteOverrides)], data: { elevation: 1 } satisfies RouteDataProperties, }, - ...unauthUiRefreshSwap( - SsoComponentV1, - ExtensionAnonLayoutWrapperComponent, - { - path: "sso", - canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { elevation: 1 } satisfies RouteDataProperties, - }, - { - path: "sso", - canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { - pageIcon: VaultIcon, - pageTitle: { - key: "enterpriseSingleSignOn", - }, - pageSubtitle: { - key: "singleSignOnEnterOrgIdentifierText", - }, - elevation: 1, - } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, - children: [ - { path: "", component: SsoComponent }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - data: { - overlayPosition: ExtensionDefaultOverlayPosition, - } satisfies EnvironmentSelectorRouteData, - }, - ], - }, - ), { path: "device-verification", component: ExtensionAnonLayoutWrapperComponent, @@ -420,158 +378,7 @@ const routes: Routes = [ canActivate: [authGuard], data: { elevation: 1 } satisfies RouteDataProperties, }, - ...unauthUiRefreshSwap( - LoginViaAuthRequestComponentV1, - ExtensionAnonLayoutWrapperComponent, - { - path: "login-with-device", - data: { elevation: 1 } satisfies RouteDataProperties, - }, - { - path: "login-with-device", - data: { - pageIcon: DevicesIcon, - pageTitle: { - key: "logInRequestSent", - }, - pageSubtitle: { - key: "aNotificationWasSentToYourDevice", - }, - showLogo: false, - showBackButton: true, - elevation: 1, - } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, - children: [ - { path: "", component: LoginViaAuthRequestComponent }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - }, - ), - ...unauthUiRefreshSwap( - LoginViaAuthRequestComponentV1, - ExtensionAnonLayoutWrapperComponent, - { - path: "admin-approval-requested", - data: { elevation: 1 } satisfies RouteDataProperties, - }, - { - path: "admin-approval-requested", - data: { - pageIcon: DevicesIcon, - pageTitle: { - key: "adminApprovalRequested", - }, - pageSubtitle: { - key: "adminApprovalRequestSentToAdmins", - }, - showLogo: false, - showBackButton: true, - elevation: 1, - } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, - children: [{ path: "", component: LoginViaAuthRequestComponent }], - }, - ), - ...unauthUiRefreshSwap( - HintComponent, - ExtensionAnonLayoutWrapperComponent, - { - path: "hint", - canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { - elevation: 1, - } satisfies RouteDataProperties, - }, - { - path: "", - children: [ - { - path: "hint", - canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { - pageTitle: { - key: "requestPasswordHint", - }, - pageSubtitle: { - key: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou", - }, - pageIcon: UserLockIcon, - showBackButton: true, - elevation: 1, - } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, - children: [ - { path: "", component: PasswordHintComponent }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - data: { - overlayPosition: ExtensionDefaultOverlayPosition, - } satisfies EnvironmentSelectorRouteData, - }, - ], - }, - ], - }, - ), - ...unauthUiRefreshSwap( - LoginComponentV1, - ExtensionAnonLayoutWrapperComponent, - { - path: "login", - canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { elevation: 1 }, - }, - { - path: "", - children: [ - { - path: "login", - canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { - pageIcon: VaultIcon, - pageTitle: { - key: "logInToBitwarden", - }, - elevation: 1, - showAcctSwitcher: true, - } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, - children: [ - { path: "", component: LoginComponent }, - { path: "", component: LoginSecondaryContentComponent, outlet: "secondary" }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - data: { - overlayPosition: ExtensionDefaultOverlayPosition, - } satisfies EnvironmentSelectorRouteData, - }, - ], - }, - ], - }, - ), - ...unauthUiRefreshSwap( - LoginDecryptionOptionsComponentV1, - ExtensionAnonLayoutWrapperComponent, - { - path: "login-initiated", - canActivate: [tdeDecryptionRequiredGuard()], - data: { elevation: 1 } satisfies RouteDataProperties, - }, - { - path: "login-initiated", - canActivate: [tdeDecryptionRequiredGuard()], - data: { - pageIcon: DevicesIcon, - }, - children: [{ path: "", component: LoginDecryptionOptionsComponent }], - }, - ), + { path: "", component: ExtensionAnonLayoutWrapperComponent, @@ -597,7 +404,7 @@ const routes: Routes = [ component: RegistrationStartSecondaryComponent, outlet: "secondary", data: { - loginRoute: "/home", + loginRoute: "/login", } satisfies RegistrationStartSecondaryComponentData, }, ], @@ -617,6 +424,127 @@ const routes: Routes = [ }, ], }, + { + path: "login", + canActivate: [unauthGuardFn(unauthRouteOverrides)], + data: { + pageIcon: VaultIcon, + pageTitle: { + key: "logInToBitwarden", + }, + elevation: 1, + showAcctSwitcher: true, + } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, + children: [ + { path: "", component: LoginComponent }, + { path: "", component: LoginSecondaryContentComponent, outlet: "secondary" }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + data: { + overlayPosition: ExtensionDefaultOverlayPosition, + } satisfies EnvironmentSelectorRouteData, + }, + ], + }, + { + path: "sso", + canActivate: [unauthGuardFn(unauthRouteOverrides)], + data: { + pageIcon: VaultIcon, + pageTitle: { + key: "enterpriseSingleSignOn", + }, + pageSubtitle: { + key: "singleSignOnEnterOrgIdentifierText", + }, + elevation: 1, + } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, + children: [ + { path: "", component: SsoComponent }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + data: { + overlayPosition: ExtensionDefaultOverlayPosition, + } satisfies EnvironmentSelectorRouteData, + }, + ], + }, + { + path: "login-with-device", + data: { + pageIcon: DevicesIcon, + pageTitle: { + key: "logInRequestSent", + }, + pageSubtitle: { + key: "aNotificationWasSentToYourDevice", + }, + showBackButton: true, + elevation: 1, + } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, + children: [ + { path: "", component: LoginViaAuthRequestComponent }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + }, + { + path: "hint", + canActivate: [unauthGuardFn(unauthRouteOverrides)], + data: { + pageTitle: { + key: "requestPasswordHint", + }, + pageSubtitle: { + key: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou", + }, + pageIcon: UserLockIcon, + showBackButton: true, + elevation: 1, + } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, + children: [ + { path: "", component: PasswordHintComponent }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + data: { + overlayPosition: ExtensionDefaultOverlayPosition, + } satisfies EnvironmentSelectorRouteData, + }, + ], + }, + { + path: "admin-approval-requested", + data: { + pageIcon: DevicesIcon, + pageTitle: { + key: "adminApprovalRequested", + }, + pageSubtitle: { + key: "adminApprovalRequestSentToAdmins", + }, + showLogo: false, + showBackButton: true, + elevation: 1, + } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, + children: [{ path: "", component: LoginViaAuthRequestComponent }], + }, + { + path: "login-initiated", + canActivate: [tdeDecryptionRequiredGuard()], + data: { + pageIcon: DevicesIcon, + }, + children: [{ path: "", component: LoginDecryptionOptionsComponent }], + }, { path: "lock", canActivate: [lockGuard()], diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 9a3b6429e61..2a7fbdce254 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -113,9 +113,7 @@ export class AppComponent implements OnInit, OnDestroy { }); this.changeDetectorRef.detectChanges(); } else if (msg.command === "authBlocked" || msg.command === "goHome") { - // 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(["home"]); + await this.router.navigate(["login"]); } else if ( msg.command === "locked" && (msg.userId == null || msg.userId == this.activeUserId) diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 19b92d4762a..3a30629b444 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -51,13 +51,8 @@ import { import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.component"; import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard"; -import { HintComponent } from "../auth/hint.component"; -import { LoginDecryptionOptionsComponentV1 } from "../auth/login/login-decryption-options/login-decryption-options-v1.component"; -import { LoginComponentV1 } from "../auth/login/login-v1.component"; -import { LoginViaAuthRequestComponentV1 } from "../auth/login/login-via-auth-request-v1.component"; import { RemovePasswordComponent } from "../auth/remove-password.component"; import { SetPasswordComponent } from "../auth/set-password.component"; -import { SsoComponentV1 } from "../auth/sso-v1.component"; import { TwoFactorComponentV1 } from "../auth/two-factor-v1.component"; import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component"; import { VaultComponent } from "../vault/app/vault/vault.component"; @@ -167,33 +162,6 @@ const routes: Routes = [ }, { path: "accessibility-cookie", component: AccessibilityCookieComponent }, { path: "set-password", component: SetPasswordComponent }, - ...unauthUiRefreshSwap( - SsoComponentV1, - AnonLayoutWrapperComponent, - { - path: "sso", - }, - { - path: "sso", - data: { - pageIcon: VaultIcon, - pageTitle: { - key: "enterpriseSingleSignOn", - }, - pageSubtitle: { - key: "singleSignOnEnterOrgIdentifierText", - }, - } satisfies AnonLayoutWrapperData, - children: [ - { path: "", component: SsoComponent }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - }, - ), { path: "send", component: SendComponent, @@ -209,139 +177,6 @@ const routes: Routes = [ component: RemovePasswordComponent, canActivate: [authGuard], }, - ...unauthUiRefreshSwap( - LoginViaAuthRequestComponentV1, - AnonLayoutWrapperComponent, - { - path: "login-with-device", - }, - { - path: "login-with-device", - data: { - pageIcon: DevicesIcon, - pageTitle: { - key: "logInRequestSent", - }, - pageSubtitle: { - key: "aNotificationWasSentToYourDevice", - }, - } satisfies AnonLayoutWrapperData, - children: [ - { path: "", component: LoginViaAuthRequestComponent }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - }, - ), - ...unauthUiRefreshSwap( - LoginViaAuthRequestComponentV1, - AnonLayoutWrapperComponent, - { - path: "admin-approval-requested", - }, - { - path: "admin-approval-requested", - data: { - pageIcon: DevicesIcon, - pageTitle: { - key: "adminApprovalRequested", - }, - pageSubtitle: { - key: "adminApprovalRequestSentToAdmins", - }, - } satisfies AnonLayoutWrapperData, - children: [{ path: "", component: LoginViaAuthRequestComponent }], - }, - ), - ...unauthUiRefreshSwap( - HintComponent, - AnonLayoutWrapperComponent, - { - path: "hint", - canActivate: [unauthGuardFn()], - }, - { - path: "", - children: [ - { - path: "hint", - canActivate: [unauthGuardFn()], - data: { - pageTitle: { - key: "requestPasswordHint", - }, - pageSubtitle: { - key: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou", - }, - pageIcon: UserLockIcon, - } satisfies AnonLayoutWrapperData, - children: [ - { path: "", component: PasswordHintComponent }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - }, - ], - }, - ), - ...unauthUiRefreshSwap( - LoginComponentV1, - AnonLayoutWrapperComponent, - { - path: "login", - component: LoginComponentV1, - canActivate: [maxAccountsGuardFn()], - }, - { - path: "", - children: [ - { - path: "login", - canActivate: [maxAccountsGuardFn()], - data: { - pageTitle: { - key: "logInToBitwarden", - }, - pageIcon: VaultIcon, - }, - children: [ - { path: "", component: LoginComponent }, - { path: "", component: LoginSecondaryContentComponent, outlet: "secondary" }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - data: { - overlayPosition: DesktopDefaultOverlayPosition, - }, - }, - ], - }, - ], - }, - ), - ...unauthUiRefreshSwap( - LoginDecryptionOptionsComponentV1, - AnonLayoutWrapperComponent, - { - path: "login-initiated", - canActivate: [tdeDecryptionRequiredGuard()], - }, - { - path: "login-initiated", - canActivate: [tdeDecryptionRequiredGuard()], - data: { - pageIcon: DevicesIcon, - }, - children: [{ path: "", component: LoginDecryptionOptionsComponent }], - }, - ), { path: "", component: AnonLayoutWrapperComponent, @@ -383,6 +218,110 @@ const routes: Routes = [ }, ], }, + { + path: "login", + canActivate: [maxAccountsGuardFn()], + data: { + pageTitle: { + key: "logInToBitwarden", + }, + pageIcon: VaultIcon, + }, + children: [ + { path: "", component: LoginComponent }, + { path: "", component: LoginSecondaryContentComponent, outlet: "secondary" }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + data: { + overlayPosition: DesktopDefaultOverlayPosition, + }, + }, + ], + }, + { + path: "login-initiated", + canActivate: [tdeDecryptionRequiredGuard()], + data: { + pageIcon: DevicesIcon, + }, + children: [{ path: "", component: LoginDecryptionOptionsComponent }], + }, + { + path: "sso", + data: { + pageIcon: VaultIcon, + pageTitle: { + key: "enterpriseSingleSignOn", + }, + pageSubtitle: { + key: "singleSignOnEnterOrgIdentifierText", + }, + } satisfies AnonLayoutWrapperData, + children: [ + { path: "", component: SsoComponent }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + }, + { + path: "login-with-device", + data: { + pageIcon: DevicesIcon, + pageTitle: { + key: "logInRequestSent", + }, + pageSubtitle: { + key: "aNotificationWasSentToYourDevice", + }, + } satisfies AnonLayoutWrapperData, + children: [ + { path: "", component: LoginViaAuthRequestComponent }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + }, + { + path: "admin-approval-requested", + data: { + pageIcon: DevicesIcon, + pageTitle: { + key: "adminApprovalRequested", + }, + pageSubtitle: { + key: "adminApprovalRequestSentToAdmins", + }, + } satisfies AnonLayoutWrapperData, + children: [{ path: "", component: LoginViaAuthRequestComponent }], + }, + { + path: "hint", + canActivate: [unauthGuardFn()], + data: { + pageTitle: { + key: "requestPasswordHint", + }, + pageSubtitle: { + key: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou", + }, + pageIcon: UserLockIcon, + } satisfies AnonLayoutWrapperData, + children: [ + { path: "", component: PasswordHintComponent }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + }, { path: "lock", canActivate: [lockGuard()], diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index adb38ef43f0..c531f358b34 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -55,10 +55,6 @@ import { AcceptFamilySponsorshipComponent } from "./admin-console/organizations/ import { FamiliesForEnterpriseSetupComponent } from "./admin-console/organizations/sponsorships/families-for-enterprise-setup.component"; import { CreateOrganizationComponent } from "./admin-console/settings/create-organization.component"; import { deepLinkGuard } from "./auth/guards/deep-link.guard"; -import { HintComponent } from "./auth/hint.component"; -import { LoginDecryptionOptionsComponentV1 } from "./auth/login/login-decryption-options/login-decryption-options-v1.component"; -import { LoginComponentV1 } from "./auth/login/login-v1.component"; -import { LoginViaAuthRequestComponentV1 } from "./auth/login/login-via-auth-request-v1.component"; import { LoginViaWebAuthnComponent } from "./auth/login/login-via-webauthn/login-via-webauthn.component"; import { AcceptOrganizationComponent } from "./auth/organization-invite/accept-organization.component"; import { RecoverDeleteComponent } from "./auth/recover-delete.component"; @@ -69,7 +65,6 @@ import { AccountComponent } from "./auth/settings/account/account.component"; import { EmergencyAccessComponent } from "./auth/settings/emergency-access/emergency-access.component"; import { EmergencyAccessViewComponent } from "./auth/settings/emergency-access/view/emergency-access-view.component"; import { SecurityRoutingModule } from "./auth/settings/security/security-routing.module"; -import { SsoComponentV1 } from "./auth/sso-v1.component"; import { TwoFactorComponentV1 } from "./auth/two-factor-v1.component"; import { UpdatePasswordComponent } from "./auth/update-password.component"; import { UpdateTempPasswordComponent } from "./auth/update-temp-password.component"; @@ -172,172 +167,6 @@ const routes: Routes = [ }, ], }, - ...unauthUiRefreshSwap( - LoginViaAuthRequestComponentV1, - AnonLayoutWrapperComponent, - { - path: "login-with-device", - data: { titleId: "loginWithDevice" } satisfies RouteDataProperties, - }, - { - path: "login-with-device", - data: { - pageIcon: DevicesIcon, - pageTitle: { - key: "logInRequestSent", - }, - pageSubtitle: { - key: "aNotificationWasSentToYourDevice", - }, - titleId: "loginInitiated", - } satisfies RouteDataProperties & AnonLayoutWrapperData, - children: [ - { path: "", component: LoginViaAuthRequestComponent }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - }, - ), - ...unauthUiRefreshSwap( - LoginViaAuthRequestComponentV1, - AnonLayoutWrapperComponent, - { - path: "admin-approval-requested", - data: { titleId: "adminApprovalRequested" } satisfies RouteDataProperties, - }, - { - path: "admin-approval-requested", - data: { - pageIcon: DevicesIcon, - pageTitle: { - key: "adminApprovalRequested", - }, - pageSubtitle: { - key: "adminApprovalRequestSentToAdmins", - }, - titleId: "adminApprovalRequested", - } satisfies RouteDataProperties & AnonLayoutWrapperData, - children: [{ path: "", component: LoginViaAuthRequestComponent }], - }, - ), - ...unauthUiRefreshSwap( - AnonLayoutWrapperComponent, - AnonLayoutWrapperComponent, - { - path: "login", - canActivate: [unauthGuardFn()], - children: [ - { - path: "", - component: LoginComponentV1, - }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - data: { - pageTitle: { - key: "logIn", - }, - }, - }, - { - path: "login", - canActivate: [unauthGuardFn()], - data: { - pageTitle: { - key: "logInToBitwarden", - }, - pageIcon: VaultIcon, - } satisfies RouteDataProperties & AnonLayoutWrapperData, - children: [ - { - path: "", - component: LoginComponent, - }, - { - path: "", - component: LoginSecondaryContentComponent, - outlet: "secondary", - }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - }, - ), - ...unauthUiRefreshSwap( - LoginDecryptionOptionsComponentV1, - AnonLayoutWrapperComponent, - { - path: "login-initiated", - canActivate: [tdeDecryptionRequiredGuard()], - }, - { - path: "login-initiated", - canActivate: [tdeDecryptionRequiredGuard()], - data: { - pageIcon: DevicesIcon, - }, - children: [{ path: "", component: LoginDecryptionOptionsComponent }], - }, - ), - ...unauthUiRefreshSwap( - AnonLayoutWrapperComponent, - AnonLayoutWrapperComponent, - { - path: "hint", - canActivate: [unauthGuardFn()], - data: { - pageTitle: { - key: "passwordHint", - }, - titleId: "passwordHint", - }, - children: [ - { path: "", component: HintComponent }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - }, - { - path: "", - children: [ - { - path: "hint", - canActivate: [unauthGuardFn()], - data: { - pageTitle: { - key: "requestPasswordHint", - }, - pageSubtitle: { - key: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou", - }, - pageIcon: UserLockIcon, - state: "hint", - }, - children: [ - { path: "", component: PasswordHintComponent }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - }, - ], - }, - ), { path: "", component: AnonLayoutWrapperComponent, @@ -381,6 +210,97 @@ const routes: Routes = [ }, ], }, + { + path: "login", + canActivate: [unauthGuardFn()], + data: { + pageTitle: { + key: "logInToBitwarden", + }, + pageIcon: VaultIcon, + } satisfies RouteDataProperties & AnonLayoutWrapperData, + children: [ + { + path: "", + component: LoginComponent, + }, + { + path: "", + component: LoginSecondaryContentComponent, + outlet: "secondary", + }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + }, + { + path: "login-with-device", + data: { + pageIcon: DevicesIcon, + pageTitle: { + key: "logInRequestSent", + }, + pageSubtitle: { + key: "aNotificationWasSentToYourDevice", + }, + titleId: "loginInitiated", + } satisfies RouteDataProperties & AnonLayoutWrapperData, + children: [ + { path: "", component: LoginViaAuthRequestComponent }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + }, + { + path: "admin-approval-requested", + data: { + pageIcon: DevicesIcon, + pageTitle: { + key: "adminApprovalRequested", + }, + pageSubtitle: { + key: "adminApprovalRequestSentToAdmins", + }, + titleId: "adminApprovalRequested", + } satisfies RouteDataProperties & AnonLayoutWrapperData, + children: [{ path: "", component: LoginViaAuthRequestComponent }], + }, + { + path: "hint", + canActivate: [unauthGuardFn()], + data: { + pageTitle: { + key: "requestPasswordHint", + }, + pageSubtitle: { + key: "enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou", + }, + pageIcon: UserLockIcon, + state: "hint", + }, + children: [ + { path: "", component: PasswordHintComponent }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + }, + { + path: "login-initiated", + canActivate: [tdeDecryptionRequiredGuard()], + data: { + pageIcon: DevicesIcon, + }, + children: [{ path: "", component: LoginDecryptionOptionsComponent }], + }, { path: "send/:sendId/:key", data: { @@ -432,64 +352,24 @@ const routes: Routes = [ }, ], }, - ...unauthUiRefreshSwap( - SsoComponentV1, - SsoComponent, - { - path: "sso", - canActivate: [unauthGuardFn()], - data: { - pageTitle: { - key: "enterpriseSingleSignOn", - }, - titleId: "enterpriseSingleSignOn", - } satisfies RouteDataProperties & AnonLayoutWrapperData, - children: [ - { - path: "", - component: SsoComponentV1, - }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - }, - { - path: "sso", - canActivate: [unauthGuardFn()], - data: { - pageTitle: { - key: "singleSignOn", - }, - titleId: "enterpriseSingleSignOn", - pageSubtitle: { - key: "singleSignOnEnterOrgIdentifierText", - }, - titleAreaMaxWidth: "md", - pageIcon: SsoKeyIcon, - } satisfies RouteDataProperties & AnonLayoutWrapperData, - children: [ - { - path: "", - component: SsoComponent, - }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - }, - ), { - path: "login", + path: "sso", canActivate: [unauthGuardFn()], + data: { + pageTitle: { + key: "singleSignOn", + }, + titleId: "enterpriseSingleSignOn", + pageSubtitle: { + key: "singleSignOnEnterOrgIdentifierText", + }, + titleAreaMaxWidth: "md", + pageIcon: SsoKeyIcon, + } satisfies RouteDataProperties & AnonLayoutWrapperData, children: [ { path: "", - component: LoginComponent, + component: SsoComponent, }, { path: "", @@ -497,11 +377,6 @@ const routes: Routes = [ outlet: "environment-selector", }, ], - data: { - pageTitle: { - key: "logIn", - }, - }, }, ...unauthUiRefreshSwap( TwoFactorComponentV1, diff --git a/libs/auth/src/angular/login/login.component.ts b/libs/auth/src/angular/login/login.component.ts index c291a64a8c5..cc38ec5dfb3 100644 --- a/libs/auth/src/angular/login/login.component.ts +++ b/libs/auth/src/angular/login/login.component.ts @@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common"; import { Component, ElementRef, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms"; import { ActivatedRoute, Router, RouterModule } from "@angular/router"; -import { firstValueFrom, Subject, take, takeUntil, tap } from "rxjs"; +import { firstValueFrom, Subject, take, takeUntil } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { @@ -19,11 +19,9 @@ import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstraction import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { ClientType, HttpStatusCode } from "@bitwarden/common/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; 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 { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -121,7 +119,6 @@ export class LoginComponent implements OnInit, OnDestroy { private toastService: ToastService, private logService: LogService, private validationService: ValidationService, - private configService: ConfigService, private loginSuccessHandlerService: LoginSuccessHandlerService, ) { this.clientType = this.platformUtilsService.getClientType(); @@ -131,9 +128,6 @@ export class LoginComponent implements OnInit, OnDestroy { // Add popstate listener to listen for browser back button clicks window.addEventListener("popstate", this.handlePopState); - // TODO: remove this when the UnauthenticatedExtensionUIRefresh feature flag is removed. - this.listenForUnauthUiRefreshFlagChanges(); - await this.defaultOnInit(); if (this.clientType === ClientType.Desktop) { @@ -154,30 +148,6 @@ export class LoginComponent implements OnInit, OnDestroy { this.destroy$.complete(); } - private listenForUnauthUiRefreshFlagChanges() { - this.configService - .getFeatureFlag$(FeatureFlag.UnauthenticatedExtensionUIRefresh) - .pipe( - tap(async (flag) => { - // If the flag is turned OFF, we must force a reload to ensure the correct UI is shown - if (!flag) { - const qParams = await firstValueFrom(this.activatedRoute.queryParams); - const uniqueQueryParams = { - ...qParams, - // adding a unique timestamp to the query params to force a reload - t: new Date().getTime().toString(), // Adding a unique timestamp as a query parameter - }; - - await this.router.navigate(["/"], { - queryParams: uniqueQueryParams, - }); - } - }), - takeUntil(this.destroy$), - ) - .subscribe(); - } - submit = async (): Promise => { if (this.clientType === ClientType.Desktop) { if (this.loginUiState !== LoginUiState.MASTER_PASSWORD_ENTRY) { From c3c4c9c54c51fab19ac56f3cee94d898139e8606 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 10 Mar 2025 11:12:24 -0700 Subject: [PATCH 11/35] bold new settings callout link (#13664) --- .../new-settings-callout/new-settings-callout.component.html | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-settings-callout/new-settings-callout.component.html b/apps/browser/src/vault/popup/components/vault-v2/new-settings-callout/new-settings-callout.component.html index a6abe8ed3ac..6cc60eed6d5 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/new-settings-callout/new-settings-callout.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/new-settings-callout/new-settings-callout.component.html @@ -17,6 +17,7 @@ Date: Mon, 10 Mar 2025 14:58:11 -0400 Subject: [PATCH 12/35] add tw class (#13765) --- .../manage/device-approvals/device-approvals.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/device-approvals/device-approvals.component.html b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/device-approvals/device-approvals.component.html index 7723324781b..cafd0744a8f 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/device-approvals/device-approvals.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/device-approvals/device-approvals.component.html @@ -1,7 +1,7 @@ From 0568a09212f56c58435a59935b3284ad93734bbf Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Mon, 10 Mar 2025 12:17:46 -0700 Subject: [PATCH 13/35] refactor(device-trust-toasts): [Auth/PM-11225] Refactor Toasts from Auth Services (#13665) Refactor toast calls out of auth services. Toasts are now triggered by an observable emission that gets picked up by an observable pipeline in a new `DeviceTrustToastService` (libs/angular). That observable pipeline is then subscribed by by consuming the `AppComponent` for each client. --- apps/browser/src/popup/app.component.ts | 7 +- apps/desktop/src/app/app.component.ts | 7 +- apps/web/src/app/app.component.ts | 7 +- .../device-trust-toast.service.abstraction.ts | 9 + ...vice-trust-toast.service.implementation.ts | 44 +++++ .../device-trust-toast.service.spec.ts | 167 ++++++++++++++++++ .../src/services/jslib-services.module.ts | 12 ++ .../auth-request.service.abstraction.ts | 15 ++ .../login-strategies/sso-login.strategy.ts | 3 +- .../auth-request/auth-request.service.ts | 9 + .../device-trust.service.abstraction.ts | 6 + .../device-trust.service.implementation.ts | 9 +- 12 files changed, 289 insertions(+), 6 deletions(-) create mode 100644 libs/angular/src/auth/services/device-trust-toast.service.abstraction.ts create mode 100644 libs/angular/src/auth/services/device-trust-toast.service.implementation.ts create mode 100644 libs/angular/src/auth/services/device-trust-toast.service.spec.ts diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 2a7fbdce254..6a08bf007bb 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -1,9 +1,11 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, inject } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router, RouterOutlet } from "@angular/router"; import { Subject, takeUntil, firstValueFrom, concatMap, filter, tap } from "rxjs"; +import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction"; import { LogoutReason } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; @@ -68,7 +70,10 @@ export class AppComponent implements OnInit, OnDestroy { private animationControlService: AnimationControlService, private biometricStateService: BiometricStateService, private biometricsService: BiometricsService, - ) {} + private deviceTrustToastService: DeviceTrustToastService, + ) { + this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); + } async ngOnInit() { initPopupClosedListener(); diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 85c159f0278..1ef03e5bb71 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -10,10 +10,12 @@ import { ViewChild, ViewContainerRef, } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; import { filter, firstValueFrom, map, Subject, takeUntil, timeout, withLatestFrom } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; +import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction"; import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; import { ModalService } from "@bitwarden/angular/services/modal.service"; import { FingerprintDialogComponent, LoginApprovalComponent } from "@bitwarden/auth/angular"; @@ -157,7 +159,10 @@ export class AppComponent implements OnInit, OnDestroy { private stateEventRunnerService: StateEventRunnerService, private accountService: AccountService, private organizationService: OrganizationService, - ) {} + private deviceTrustToastService: DeviceTrustToastService, + ) { + this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); + } ngOnInit() { this.accountService.activeAccount$.pipe(takeUntil(this.destroy$)).subscribe((account) => { diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 9d2afb22688..f6e038f85d9 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -2,11 +2,13 @@ // @ts-strict-ignore import { DOCUMENT } from "@angular/common"; import { Component, Inject, NgZone, OnDestroy, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router } from "@angular/router"; import * as jq from "jquery"; import { Subject, filter, firstValueFrom, map, takeUntil, timeout } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; +import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; @@ -95,7 +97,10 @@ export class AppComponent implements OnDestroy, OnInit { private apiService: ApiService, private appIdService: AppIdService, private processReloadService: ProcessReloadServiceAbstraction, - ) {} + private deviceTrustToastService: DeviceTrustToastService, + ) { + this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); + } ngOnInit() { this.i18nService.locale$.pipe(takeUntil(this.destroy$)).subscribe((locale) => { diff --git a/libs/angular/src/auth/services/device-trust-toast.service.abstraction.ts b/libs/angular/src/auth/services/device-trust-toast.service.abstraction.ts new file mode 100644 index 00000000000..3de288168b1 --- /dev/null +++ b/libs/angular/src/auth/services/device-trust-toast.service.abstraction.ts @@ -0,0 +1,9 @@ +import { Observable } from "rxjs"; + +export abstract class DeviceTrustToastService { + /** + * An observable pipeline that observes any cross-application toast messages + * that need to be shown as part of the trusted device encryption (TDE) process. + */ + abstract setupListeners$: Observable; +} diff --git a/libs/angular/src/auth/services/device-trust-toast.service.implementation.ts b/libs/angular/src/auth/services/device-trust-toast.service.implementation.ts new file mode 100644 index 00000000000..330519683f3 --- /dev/null +++ b/libs/angular/src/auth/services/device-trust-toast.service.implementation.ts @@ -0,0 +1,44 @@ +import { merge, Observable, tap } from "rxjs"; + +import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common"; +import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ToastService } from "@bitwarden/components"; + +import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "./device-trust-toast.service.abstraction"; + +export class DeviceTrustToastService implements DeviceTrustToastServiceAbstraction { + private adminLoginApproved$: Observable; + private deviceTrusted$: Observable; + + setupListeners$: Observable; + + constructor( + private authRequestService: AuthRequestServiceAbstraction, + private deviceTrustService: DeviceTrustServiceAbstraction, + private i18nService: I18nService, + private toastService: ToastService, + ) { + this.adminLoginApproved$ = this.authRequestService.adminLoginApproved$.pipe( + tap(() => { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("loginApproved"), + }); + }), + ); + + this.deviceTrusted$ = this.deviceTrustService.deviceTrusted$.pipe( + tap(() => { + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("deviceTrusted"), + }); + }), + ); + + this.setupListeners$ = merge(this.adminLoginApproved$, this.deviceTrusted$); + } +} diff --git a/libs/angular/src/auth/services/device-trust-toast.service.spec.ts b/libs/angular/src/auth/services/device-trust-toast.service.spec.ts new file mode 100644 index 00000000000..cd9c6b0acf5 --- /dev/null +++ b/libs/angular/src/auth/services/device-trust-toast.service.spec.ts @@ -0,0 +1,167 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { EMPTY, of } from "rxjs"; + +import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common"; +import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { ToastService } from "@bitwarden/components"; + +import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "./device-trust-toast.service.abstraction"; +import { DeviceTrustToastService } from "./device-trust-toast.service.implementation"; + +describe("DeviceTrustToastService", () => { + let authRequestService: MockProxy; + let deviceTrustService: MockProxy; + let i18nService: MockProxy; + let toastService: MockProxy; + + let sut: DeviceTrustToastServiceAbstraction; + + beforeEach(() => { + authRequestService = mock(); + deviceTrustService = mock(); + i18nService = mock(); + toastService = mock(); + + i18nService.t.mockImplementation((key: string) => key); // just return the key that was given + }); + + const initService = () => { + return new DeviceTrustToastService( + authRequestService, + deviceTrustService, + i18nService, + toastService, + ); + }; + + const loginApprovalToastOptions = { + variant: "success", + title: "", + message: "loginApproved", + }; + + const deviceTrustedToastOptions = { + variant: "success", + title: "", + message: "deviceTrusted", + }; + + describe("setupListeners$", () => { + describe("given adminLoginApproved$ emits and deviceTrusted$ emits", () => { + beforeEach(() => { + // Arrange + authRequestService.adminLoginApproved$ = of(undefined); + deviceTrustService.deviceTrusted$ = of(undefined); + sut = initService(); + }); + + it("should trigger a toast for login approval", (done) => { + // Act + sut.setupListeners$.subscribe({ + complete: () => { + expect(toastService.showToast).toHaveBeenCalledWith(loginApprovalToastOptions); // Assert + done(); + }, + }); + }); + + it("should trigger a toast for device trust", (done) => { + // Act + sut.setupListeners$.subscribe({ + complete: () => { + expect(toastService.showToast).toHaveBeenCalledWith(deviceTrustedToastOptions); // Assert + done(); + }, + }); + }); + }); + + describe("given adminLoginApproved$ emits and deviceTrusted$ does not emit", () => { + beforeEach(() => { + // Arrange + authRequestService.adminLoginApproved$ = of(undefined); + deviceTrustService.deviceTrusted$ = EMPTY; + sut = initService(); + }); + + it("should trigger a toast for login approval", (done) => { + // Act + sut.setupListeners$.subscribe({ + complete: () => { + expect(toastService.showToast).toHaveBeenCalledWith(loginApprovalToastOptions); // Assert + done(); + }, + }); + }); + + it("should NOT trigger a toast for device trust", (done) => { + // Act + sut.setupListeners$.subscribe({ + complete: () => { + expect(toastService.showToast).not.toHaveBeenCalledWith(deviceTrustedToastOptions); // Assert + done(); + }, + }); + }); + }); + + describe("given adminLoginApproved$ does not emit and deviceTrusted$ emits", () => { + beforeEach(() => { + // Arrange + authRequestService.adminLoginApproved$ = EMPTY; + deviceTrustService.deviceTrusted$ = of(undefined); + sut = initService(); + }); + + it("should NOT trigger a toast for login approval", (done) => { + // Act + sut.setupListeners$.subscribe({ + complete: () => { + expect(toastService.showToast).not.toHaveBeenCalledWith(loginApprovalToastOptions); // Assert + done(); + }, + }); + }); + + it("should trigger a toast for device trust", (done) => { + // Act + sut.setupListeners$.subscribe({ + complete: () => { + expect(toastService.showToast).toHaveBeenCalledWith(deviceTrustedToastOptions); // Assert + done(); + }, + }); + }); + }); + + describe("given adminLoginApproved$ does not emit and deviceTrusted$ does not emit", () => { + beforeEach(() => { + // Arrange + authRequestService.adminLoginApproved$ = EMPTY; + deviceTrustService.deviceTrusted$ = EMPTY; + sut = initService(); + }); + + it("should NOT trigger a toast for login approval", (done) => { + // Act + sut.setupListeners$.subscribe({ + complete: () => { + expect(toastService.showToast).not.toHaveBeenCalledWith(loginApprovalToastOptions); // Assert + done(); + }, + }); + }); + + it("should NOT trigger a toast for device trust", (done) => { + // Act + sut.setupListeners$.subscribe({ + complete: () => { + expect(toastService.showToast).not.toHaveBeenCalledWith(deviceTrustedToastOptions); // Assert + done(); + }, + }); + }); + }); + }); +}); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 9ee49a30689..93e29846e69 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -317,6 +317,8 @@ import { IndividualVaultExportServiceAbstraction, } from "@bitwarden/vault-export-core"; +import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction"; +import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation"; import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service"; import { ViewCacheService } from "../platform/abstractions/view-cache.service"; import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service"; @@ -1463,6 +1465,16 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultTaskService, deps: [StateProvider, ApiServiceAbstraction, OrganizationServiceAbstraction, ConfigService], }), + safeProvider({ + provide: DeviceTrustToastServiceAbstraction, + useClass: DeviceTrustToastService, + deps: [ + AuthRequestServiceAbstraction, + DeviceTrustServiceAbstraction, + I18nServiceAbstraction, + ToastService, + ], + }), ]; @NgModule({ 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 1829fe6b0c9..75bb8686163 100644 --- a/libs/auth/src/common/abstractions/auth-request.service.abstraction.ts +++ b/libs/auth/src/common/abstractions/auth-request.service.abstraction.ts @@ -12,6 +12,12 @@ export abstract class AuthRequestServiceAbstraction { /** Emits an auth request id when an auth request has been approved. */ authRequestPushNotification$: Observable; + /** + * Emits when a login has been approved by an admin. This emission is specifically for the + * purpose of notifying the consuming component to display a toast informing the user. + */ + adminLoginApproved$: Observable; + /** * Returns an admin auth request for the given user if it exists. * @param userId The user id. @@ -106,4 +112,13 @@ export abstract class AuthRequestServiceAbstraction { * @returns The dash-delimited fingerprint phrase. */ abstract getFingerprintPhrase(email: string, publicKey: Uint8Array): Promise; + + /** + * Passes a value to the adminLoginApprovedSubject via next(), which causes the + * adminLoginApproved$ observable to emit. + * + * The purpose is to notify consuming components (of adminLoginApproved$) to display + * a toast informing the user that a login has been approved by an admin. + */ + abstract emitAdminLoginApproved(): void; } 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 e9d1e0a6c88..ac00ff69a4d 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.ts @@ -278,7 +278,8 @@ export class SsoLoginStrategy extends LoginStrategy { // TODO: eventually we post and clean up DB as well once consumed on client await this.authRequestService.clearAdminAuthRequest(userId); - this.platformUtilsService.showToast("success", null, this.i18nService.t("loginApproved")); + // This notification will be picked up by the SsoComponent to handle displaying a toast to the user + this.authRequestService.emitAdminLoginApproved(); } } } 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 5bc200ae1e8..a6841afe0ff 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 @@ -43,6 +43,10 @@ export class AuthRequestService implements AuthRequestServiceAbstraction { private authRequestPushNotificationSubject = new Subject(); authRequestPushNotification$: Observable; + // Observable emission is used to trigger a toast in consuming components + private adminLoginApprovedSubject = new Subject(); + adminLoginApproved$: Observable; + constructor( private appIdService: AppIdService, private accountService: AccountService, @@ -53,6 +57,7 @@ export class AuthRequestService implements AuthRequestServiceAbstraction { private stateProvider: StateProvider, ) { this.authRequestPushNotification$ = this.authRequestPushNotificationSubject.asObservable(); + this.adminLoginApproved$ = this.adminLoginApprovedSubject.asObservable(); } async getAdminAuthRequest(userId: UserId): Promise { @@ -207,4 +212,8 @@ export class AuthRequestService implements AuthRequestServiceAbstraction { async getFingerprintPhrase(email: string, publicKey: Uint8Array): Promise { return (await this.keyService.getFingerprint(email.toLowerCase(), publicKey)).join("-"); } + + emitAdminLoginApproved(): void { + this.adminLoginApprovedSubject.next(); + } } diff --git a/libs/common/src/auth/abstractions/device-trust.service.abstraction.ts b/libs/common/src/auth/abstractions/device-trust.service.abstraction.ts index 24a5d4e8413..2de63b36cc7 100644 --- a/libs/common/src/auth/abstractions/device-trust.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/device-trust.service.abstraction.ts @@ -16,6 +16,12 @@ export abstract class DeviceTrustServiceAbstraction { */ supportsDeviceTrust$: Observable; + /** + * Emits when a device has been trusted. This emission is specifically for the purpose of notifying + * the consuming component to display a toast informing the user the device has been trusted. + */ + deviceTrusted$: Observable; + /** * @description Checks if the device trust feature is supported for the given user. */ diff --git a/libs/common/src/auth/services/device-trust.service.implementation.ts b/libs/common/src/auth/services/device-trust.service.implementation.ts index fe43df53c48..4a1b901d5ef 100644 --- a/libs/common/src/auth/services/device-trust.service.implementation.ts +++ b/libs/common/src/auth/services/device-trust.service.implementation.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom, map, Observable } from "rxjs"; +import { firstValueFrom, map, Observable, Subject } from "rxjs"; import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; import { KeyService } from "@bitwarden/key-management"; @@ -63,6 +63,10 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction { supportsDeviceTrust$: Observable; + // Observable emission is used to trigger a toast in consuming components + private deviceTrustedSubject = new Subject(); + deviceTrusted$ = this.deviceTrustedSubject.asObservable(); + constructor( private keyGenerationService: KeyGenerationService, private cryptoFunctionService: CryptoFunctionService, @@ -177,7 +181,8 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction { // store device key in local/secure storage if enc keys posted to server successfully await this.setDeviceKey(userId, deviceKey); - this.platformUtilsService.showToast("success", null, this.i18nService.t("deviceTrusted")); + // This emission will be picked up by consuming components to handle displaying a toast to the user + this.deviceTrustedSubject.next(); return deviceResponse; } From 337597cf818712f872ac1dd64fece2b2f5c9aa6e Mon Sep 17 00:00:00 2001 From: Alec Rippberger <127791530+alec-livefront@users.noreply.github.com> Date: Mon, 10 Mar 2025 14:23:42 -0500 Subject: [PATCH 14/35] fix(auth): [PM-10775] Fix spacing of horizontal rules in SSO component - Remove horizontal rule above "Member decryption options" section - Add 1rem margin below horizontal rule before "type" section Resolves PM-10775 --- bitwarden_license/bit-web/src/app/auth/sso/sso.component.html | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html index 22887cb7094..9cead9d21f3 100644 --- a/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html +++ b/bitwarden_license/bit-web/src/app/auth/sso/sso.component.html @@ -38,8 +38,6 @@ -
- {{ "memberDecryptionOption" | i18n }} @@ -156,7 +154,7 @@
-
+
{{ "type" | i18n }} From f682870e4117aea3fd8cb2973bdb8a8e36d09ff4 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Mon, 10 Mar 2025 15:36:21 -0400 Subject: [PATCH 15/35] remove class, add tw class (#13768) --- .../domain-verification/domain-verification.component.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.html b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.html index c292d51ebda..adf9fcd2dcf 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.html @@ -24,7 +24,7 @@ @@ -74,7 +74,7 @@ {{ orgDomain.lastCheckedDate | date: "medium" }} - +

diff --git a/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.html b/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.html index af336c94854..ded456ff963 100644 --- a/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.html +++ b/apps/web/src/app/tools/reports/pages/reused-passwords-report.component.html @@ -33,73 +33,69 @@ - + - - - {{ "name" | i18n }} - {{ "owner" | i18n }} - {{ "timesReused" | i18n }} - + + {{ "name" | i18n }} + {{ "owner" | i18n }} + {{ "timesReused" | i18n }} - - - - - - - - {{ r.name }} - - - {{ r.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
- {{ r.subTitle }} - - - + + + + + + {{ row.name }} - - - - - {{ "reusedXTimes" | i18n: passwordUseMap.get(r.login.password) }} - - - + + + {{ row.name }} + + + + {{ "shared" | i18n }} + + + + {{ "attachments" | i18n }} + +
+ {{ row.subTitle }} + + + + + + + + {{ "reusedXTimes" | i18n: passwordUseMap.get(row.login.password) }} + +
-
+ diff --git a/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.html b/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.html index 9c5b587e60a..3ef1d11f9b2 100644 --- a/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.html +++ b/apps/web/src/app/tools/reports/pages/weak-passwords-report.component.html @@ -31,77 +31,73 @@ - + - - - {{ "name" | i18n }} - - {{ "owner" | i18n }} - - - {{ "weakness" | i18n }} - - + + {{ "name" | i18n }} + + {{ "owner" | i18n }} + + + {{ "weakness" | i18n }} + - - - - - - - - {{ r.name }} - - - {{ r.name }} - - - - {{ "shared" | i18n }} - - - - {{ "attachments" | i18n }} - -
- {{ r.subTitle }} - - - + + + + + + {{ row.name }} - - - - - {{ r.reportValue.label | i18n }} - - - + + + {{ row.name }} + + + + {{ "shared" | i18n }} + + + + {{ "attachments" | i18n }} + +
+ {{ row.subTitle }} + + + + + + + + {{ row.reportValue.label | i18n }} + +
-
+ diff --git a/libs/components/src/table/table-scroll.component.html b/libs/components/src/table/table-scroll.component.html index 26b06ee0e5c..8f2c88ba3ad 100644 --- a/libs/components/src/table/table-scroll.component.html +++ b/libs/components/src/table/table-scroll.component.html @@ -12,7 +12,7 @@ - + From 992be1d054e30cb6b1b2b9c357f80fcb27b9e0cb Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 10 Mar 2025 12:57:02 -0700 Subject: [PATCH 17/35] [PM-13991] - Edit login - reorder website URIs (#13595) * WIP - sortable website uri * add specs * fix type errors in tests --- apps/browser/src/_locales/en/messages.json | 6 + apps/web/src/locales/en/messages.json | 37 ++++++ .../autofill-options.component.html | 5 +- .../autofill-options.component.spec.ts | 116 ++++++++++++++++++ .../autofill-options.component.ts | 56 +++++++++ .../uri-option.component.html | 81 +++++++----- .../autofill-options/uri-option.component.ts | 15 +++ 7 files changed, 282 insertions(+), 34 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index cb3d8c12ef4..127e07f25e8 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1679,6 +1679,9 @@ "dragToSort": { "message": "Drag to sort" }, + "dragToReorder": { + "message": "Drag to reorder" + }, "cfTypeText": { "message": "Text" }, @@ -4706,6 +4709,9 @@ } } }, + "reorderWebsiteUriButton": { + "message": "Reorder website URI. Use arrow key to move item up or down." + }, "reorderFieldUp": { "message": "$LABEL$ moved up, position $INDEX$ of $LENGTH$", "placeholders": { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 1948f589661..22d2869b4b5 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -449,6 +449,9 @@ "dragToSort": { "message": "Drag to sort" }, + "dragToReorder": { + "message": "Drag to reorder" + }, "cfTypeText": { "message": "Text" }, @@ -4564,6 +4567,40 @@ } } }, + "reorderFieldUp": { + "message": "$LABEL$ moved up, position $INDEX$ of $LENGTH$", + "placeholders": { + "label": { + "content": "$1", + "example": "Custom field" + }, + "index": { + "content": "$2", + "example": "1" + }, + "length": { + "content": "$3", + "example": "3" + } + } + }, + "reorderFieldDown": { + "message": "$LABEL$ moved down, position $INDEX$ of $LENGTH$", + "placeholders": { + "label": { + "content": "$1", + "example": "Custom field" + }, + "index": { + "content": "$2", + "example": "1" + }, + "length": { + "content": "$3", + "example": "3" + } + } + }, "keyUpdateFoldersFailed": { "message": "When updating your encryption key, your folders could not be decrypted. To continue with the update, your folders must be deleted. No vault items will be deleted if you proceed." }, diff --git a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.html b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.html index 6f7dd35be9e..42164ae6854 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.html +++ b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.html @@ -5,12 +5,15 @@ - + { + const actual = jest.requireActual("@angular/cdk/drag-drop"); + return { + ...actual, + moveItemInArray: jest.fn(actual.moveItemInArray), + }; +}); + describe("AutofillOptionsComponent", () => { let component: AutofillOptionsComponent; let fixture: ComponentFixture; @@ -255,4 +264,111 @@ describe("AutofillOptionsComponent", () => { expect(component.autofillOptionsForm.value.uris.length).toEqual(1); }); + + describe("Drag & Drop Functionality", () => { + beforeEach(() => { + // Prevent auto‑adding an empty URI by setting a non‑null initial value. + // This overrides the call to initNewCipher. + + // Now clear any existing URIs (including the auto‑added one) + component.autofillOptionsForm.controls.uris.clear(); + + // Add exactly three URIs that we want to test reordering on. + component.addUri({ uri: "https://first.com", matchDetection: null }); + component.addUri({ uri: "https://second.com", matchDetection: null }); + component.addUri({ uri: "https://third.com", matchDetection: null }); + fixture.detectChanges(); + }); + + it("should reorder URI inputs on drop event", () => { + // Simulate a drop event that moves the first URI (index 0) to the last position (index 2). + const dropEvent: CdkDragDrop = { + previousIndex: 0, + currentIndex: 2, + container: null, + previousContainer: null, + isPointerOverContainer: true, + item: null, + distance: { x: 0, y: 0 }, + } as any; + + component.onUriItemDrop(dropEvent); + fixture.detectChanges(); + + expect(moveItemInArray).toHaveBeenCalledWith( + component.autofillOptionsForm.controls.uris.controls, + 0, + 2, + ); + }); + + it("should reorder URI input via keyboard ArrowUp", async () => { + // Clear and add exactly two URIs. + component.autofillOptionsForm.controls.uris.clear(); + component.addUri({ uri: "https://first.com", matchDetection: null }); + component.addUri({ uri: "https://second.com", matchDetection: null }); + fixture.detectChanges(); + + // Simulate pressing ArrowUp on the second URI (index 1) + const keyEvent = { + key: "ArrowUp", + preventDefault: jest.fn(), + target: document.createElement("button"), + } as unknown as KeyboardEvent; + + // Force requestAnimationFrame to run synchronously + jest.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => { + cb(new Date().getTime()); + return 0; + }); + (liveAnnouncer.announce as jest.Mock).mockResolvedValue(null); + + await component.onUriItemKeydown(keyEvent, 1); + fixture.detectChanges(); + + expect(moveItemInArray).toHaveBeenCalledWith( + component.autofillOptionsForm.controls.uris.controls, + 1, + 0, + ); + expect(liveAnnouncer.announce).toHaveBeenCalledWith( + "reorderFieldUp websiteUri 1 2", + "assertive", + ); + }); + + it("should reorder URI input via keyboard ArrowDown", async () => { + // Clear and add exactly three URIs. + component.autofillOptionsForm.controls.uris.clear(); + component.addUri({ uri: "https://first.com", matchDetection: null }); + component.addUri({ uri: "https://second.com", matchDetection: null }); + component.addUri({ uri: "https://third.com", matchDetection: null }); + fixture.detectChanges(); + + // Simulate pressing ArrowDown on the second URI (index 1) + const keyEvent = { + key: "ArrowDown", + preventDefault: jest.fn(), + target: document.createElement("button"), + } as unknown as KeyboardEvent; + + jest.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => { + cb(new Date().getTime()); + return 0; + }); + (liveAnnouncer.announce as jest.Mock).mockResolvedValue(null); + + await component.onUriItemKeydown(keyEvent, 1); + + expect(moveItemInArray).toHaveBeenCalledWith( + component.autofillOptionsForm.controls.uris.controls, + 1, + 2, + ); + expect(liveAnnouncer.announce).toHaveBeenCalledWith( + "reorderFieldDown websiteUri 3 3", + "assertive", + ); + }); + }); }); diff --git a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts index c3b2a0fb9f9..5b1e4eca103 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/autofill-options.component.ts @@ -1,6 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { LiveAnnouncer } from "@angular/cdk/a11y"; +import { CdkDragDrop, DragDropModule, moveItemInArray } from "@angular/cdk/drag-drop"; import { AsyncPipe, NgForOf, NgIf } from "@angular/common"; import { Component, OnInit, QueryList, ViewChildren } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; @@ -41,6 +42,7 @@ interface UriField { templateUrl: "./autofill-options.component.html", standalone: true, imports: [ + DragDropModule, SectionComponent, SectionHeaderComponent, TypographyModule, @@ -229,4 +231,58 @@ export class AutofillOptionsComponent implements OnInit { removeUri(i: number) { this.autofillOptionsForm.controls.uris.removeAt(i); } + + /** Create a new list of LoginUriViews from the form objects and update the cipher */ + private updateUriFields() { + this.cipherFormContainer.patchCipher((cipher) => { + cipher.login.uris = this.uriControls.map( + (control) => + Object.assign(new LoginUriView(), { + uri: control.value.uri, + matchDetection: control.value.matchDetection ?? null, + }) as LoginUriView, + ); + return cipher; + }); + } + + /** Reorder the controls to match the new order after a "drop" event */ + onUriItemDrop(event: CdkDragDrop) { + moveItemInArray(this.uriControls, event.previousIndex, event.currentIndex); + this.updateUriFields(); + } + + /** Handles a uri item keyboard up or down event */ + async onUriItemKeydown(event: KeyboardEvent, index: number) { + if (event.key === "ArrowUp" && index !== 0) { + await this.reorderUriItems(event, index, "Up"); + } + + if (event.key === "ArrowDown" && index !== this.uriControls.length - 1) { + await this.reorderUriItems(event, index, "Down"); + } + } + + /** Reorders the uri items from a keyboard up or down event */ + async reorderUriItems(event: KeyboardEvent, previousIndex: number, direction: "Up" | "Down") { + const currentIndex = previousIndex + (direction === "Up" ? -1 : 1); + event.preventDefault(); + await this.liveAnnouncer.announce( + this.i18nService.t( + `reorderField${direction}`, + this.i18nService.t("websiteUri"), + currentIndex + 1, + this.uriControls.length, + ), + "assertive", + ); + moveItemInArray(this.uriControls, previousIndex, currentIndex); + this.updateUriFields(); + // Refocus the button after the reorder + // Angular re-renders the list when moving an item up which causes the focus to be lost + // Wait for the next tick to ensure the button is rendered before focusing + requestAnimationFrame(() => { + (event.target as HTMLButtonElement).focus(); + }); + } } diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.html b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.html index a55716083de..5301e4f32b9 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.html +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.html @@ -1,35 +1,50 @@ - - {{ uriLabel }} - - - - - - - {{ "matchDetection" | i18n }} - - - - +
+
+ + {{ uriLabel }} + + + + +
+ +
+
+ + {{ "matchDetection" | i18n }} + + + + +
diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts index f712e3114e0..07bf7bef775 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.ts @@ -1,5 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { DragDropModule } from "@angular/cdk/drag-drop"; import { NgForOf, NgIf } from "@angular/common"; import { Component, @@ -43,6 +44,7 @@ import { }, ], imports: [ + DragDropModule, FormFieldModule, ReactiveFormsModule, IconButtonModule, @@ -74,6 +76,12 @@ export class UriOptionComponent implements ControlValueAccessor { { label: this.i18nService.t("never"), value: UriMatchStrategy.Never }, ]; + /** + * Whether the option can be reordered. If false, the reorder button will be hidden. + */ + @Input({ required: true }) + canReorder: boolean; + /** * Whether the URI can be removed from the form. If false, the remove button will be hidden. */ @@ -101,6 +109,9 @@ export class UriOptionComponent implements ControlValueAccessor { */ @Input({ required: true }) index: number; + @Output() + onKeydown = new EventEmitter(); + /** * Emits when the remove button is clicked and URI should be removed from the form. */ @@ -132,6 +143,10 @@ export class UriOptionComponent implements ControlValueAccessor { private onChange: any = () => {}; private onTouched: any = () => {}; + protected handleKeydown(event: KeyboardEvent) { + this.onKeydown.emit(event); + } + constructor( private formBuilder: FormBuilder, private i18nService: I18nService, From 3b9be21fd7e37c9a42b7de722c974ed21f5f777b Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Mon, 10 Mar 2025 21:20:11 -0400 Subject: [PATCH 18/35] fix(auth-routing): [PM-19018] SSO TDE Routing Fix - Fixed routing logic. (#13778) * fix(auth-routing): [PM-19018] SSO TDE Routing Fix - Fixed routing logic. * PM-19018 - TwoFactorAuthTests - remove tests that are no longer applicable as 2FA comp isn't responsible for setting admin account recovery flag into state. * PM-19018 - LoginStrategyTests - add test for processing forcePasswordReset response --------- Co-authored-by: Jared Snider --- .../two-factor-auth.component.spec.ts | 59 +------------------ .../two-factor-auth.component.ts | 14 +---- .../login-strategies/login.strategy.spec.ts | 25 ++++++++ .../common/login-strategies/login.strategy.ts | 25 +++++--- .../login-strategies/sso-login.strategy.ts | 9 --- 5 files changed, 44 insertions(+), 88 deletions(-) diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts index 46b27a5aa42..6b7fca47ad5 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts @@ -226,20 +226,6 @@ describe("TwoFactorAuthComponent", () => { }); }; - const testForceResetOnSuccessfulLogin = (reasonString: string) => { - it(`navigates to the component's defined forcePasswordResetRoute route when response.forcePasswordReset is ${reasonString}`, async () => { - // Act - await component.submit("testToken"); - - // expect(mockRouter.navigate).toHaveBeenCalledTimes(1); - expect(mockRouter.navigate).toHaveBeenCalledWith(["update-temp-password"], { - queryParams: { - identifier: component.orgSsoIdentifier, - }, - }); - }); - }; - describe("Standard 2FA scenarios", () => { describe("submit", () => { const token = "testToken"; @@ -311,26 +297,6 @@ describe("TwoFactorAuthComponent", () => { }); }); - describe("Force Master Password Reset scenarios", () => { - [ - ForceSetPasswordReason.AdminForcePasswordReset, - ForceSetPasswordReason.WeakMasterPassword, - ].forEach((forceResetPasswordReason) => { - const reasonString = ForceSetPasswordReason[forceResetPasswordReason]; - - beforeEach(() => { - // use standard user with MP because this test is not concerned with password reset. - selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword); - - const authResult = new AuthResult(); - authResult.forcePasswordReset = forceResetPasswordReason; - mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult); - }); - - testForceResetOnSuccessfulLogin(reasonString); - }); - }); - it("navigates to the component's defined success route (vault is default) when the login is successful", async () => { mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult()); @@ -407,29 +373,7 @@ describe("TwoFactorAuthComponent", () => { }); }); - describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is required", () => { - [ - ForceSetPasswordReason.AdminForcePasswordReset, - ForceSetPasswordReason.WeakMasterPassword, - ].forEach((forceResetPasswordReason) => { - const reasonString = ForceSetPasswordReason[forceResetPasswordReason]; - - beforeEach(() => { - // use standard user with MP because this test is not concerned with password reset. - selectedUserDecryptionOptions.next( - mockUserDecryptionOpts.withMasterPasswordAndTrustedDevice, - ); - - const authResult = new AuthResult(); - authResult.forcePasswordReset = forceResetPasswordReason; - mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult); - }); - - testForceResetOnSuccessfulLogin(reasonString); - }); - }); - - describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is not required", () => { + describe("Given Trusted Device Encryption is enabled and user doesn't need to set a MP", () => { let authResult; beforeEach(() => { selectedUserDecryptionOptions.next( @@ -437,7 +381,6 @@ describe("TwoFactorAuthComponent", () => { ); authResult = new AuthResult(); - authResult.forcePasswordReset = ForceSetPasswordReason.None; mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult); }); diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts index 296316198b0..c5e174484b0 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts @@ -396,11 +396,6 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy { ); } - // note: this flow affects both TDE & standard users - if (this.isForcePasswordResetRequired(authResult)) { - return await this.handleForcePasswordReset(this.orgSsoIdentifier); - } - const userDecryptionOpts = await firstValueFrom( this.userDecryptionOptionsService.userDecryptionOptions$, ); @@ -415,6 +410,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy { const requireSetPassword = !userDecryptionOpts.hasMasterPassword && userDecryptionOpts.keyConnectorOption === undefined; + // New users without a master password must set a master password before advancing. if (requireSetPassword || authResult.resetMasterPassword) { // Change implies going no password -> password in this case return await this.handleChangePasswordRequired(this.orgSsoIdentifier); @@ -524,14 +520,6 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy { return forceResetReasons.includes(authResult.forcePasswordReset); } - private async handleForcePasswordReset(orgIdentifier: string | undefined) { - await this.router.navigate(["update-temp-password"], { - queryParams: { - identifier: orgIdentifier, - }, - }); - } - showContinueButton() { return ( this.selectedProviderType != null && 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 b4a1e6a77d9..290345a90c7 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.spec.ts @@ -306,6 +306,31 @@ describe("LoginStrategy", () => { expect(result).toEqual(expected); }); + it("processes a forcePasswordReset response properly", async () => { + const tokenResponse = identityTokenResponseFactory(); + tokenResponse.forcePasswordReset = true; + + apiService.postIdentityToken.mockResolvedValue(tokenResponse); + + const result = await passwordLoginStrategy.logIn(credentials); + + const expected = new AuthResult(); + expected.userId = userId; + expected.forcePasswordReset = ForceSetPasswordReason.AdminForcePasswordReset; + expected.resetMasterPassword = false; + expected.twoFactorProviders = {} as Partial< + Record> + >; + expected.captchaSiteKey = ""; + expected.twoFactorProviders = null; + expect(result).toEqual(expected); + + expect(masterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith( + ForceSetPasswordReason.AdminForcePasswordReset, + userId, + ); + }); + it("rejects login if CAPTCHA is required", async () => { // Sample CAPTCHA response const tokenResponse = new IdentityCaptchaResponse({ diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index 89802c609c0..1d4c23d3bab 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -271,17 +271,24 @@ export abstract class LoginStrategy { } } - result.resetMasterPassword = response.resetMasterPassword; - - // Convert boolean to enum - if (response.forcePasswordReset) { - result.forcePasswordReset = ForceSetPasswordReason.AdminForcePasswordReset; - } - - // Must come before setting keys, user key needs email to update additional keys + // Must come before setting keys, user key needs email to update additional keys. const userId = await this.saveAccountInformation(response); result.userId = userId; + result.resetMasterPassword = response.resetMasterPassword; + + // Convert boolean to enum and set the state for the master password service to + // so we know when we reach the auth guard that we need to guide them properly to admin + // password reset. + if (response.forcePasswordReset) { + result.forcePasswordReset = ForceSetPasswordReason.AdminForcePasswordReset; + + await this.masterPasswordService.setForceSetPasswordReason( + ForceSetPasswordReason.AdminForcePasswordReset, + userId, + ); + } + if (response.twoFactorToken != null) { // note: we can read email from access token b/c it was saved in saveAccountInformation const userEmail = await this.tokenService.getEmail(); @@ -300,7 +307,9 @@ export abstract class LoginStrategy { // The keys comes from different sources depending on the login strategy protected abstract setMasterKey(response: IdentityTokenResponse, userId: UserId): Promise; + protected abstract setUserKey(response: IdentityTokenResponse, userId: UserId): Promise; + protected abstract setPrivateKey(response: IdentityTokenResponse, userId: UserId): 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/sso-login.strategy.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.ts index ac00ff69a4d..f4eaa10c319 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.ts @@ -6,7 +6,6 @@ import { Jsonify } from "type-fest"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; -import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { SsoTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/sso-token.request"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response"; @@ -108,14 +107,6 @@ export class SsoLoginStrategy extends LoginStrategy { const email = ssoAuthResult.email; const ssoEmail2FaSessionToken = ssoAuthResult.ssoEmail2FaSessionToken; - // Auth guard currently handles redirects for this. - if (ssoAuthResult.forcePasswordReset == ForceSetPasswordReason.AdminForcePasswordReset) { - await this.masterPasswordService.setForceSetPasswordReason( - ssoAuthResult.forcePasswordReset, - ssoAuthResult.userId, - ); - } - this.cache.next({ ...this.cache.value, email, From 7e6f2fa79803bfd8231c44b609133d4f31b5eb89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Tue, 11 Mar 2025 09:03:28 +0100 Subject: [PATCH 19/35] Enable Basic Desktop Modal Support (#11484) Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> Co-authored-by: Colton Hurst Co-authored-by: Andreas Coroiu --- .github/CODEOWNERS | 1 + apps/desktop/src/app/app-routing.module.ts | 5 + .../components/fido2placeholder.component.ts | 36 ++++++ apps/desktop/src/main.ts | 2 + apps/desktop/src/main/tray.main.ts | 43 +++++++- apps/desktop/src/main/window.main.ts | 103 +++++++++++++++--- .../src/platform/popup-modal-styles.ts | 52 +++++++++ .../services/desktop-settings.service.ts | 24 ++++ 8 files changed, 248 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src/app/components/fido2placeholder.component.ts create mode 100644 apps/desktop/src/platform/popup-modal-styles.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d23cfa58283..5ba84c1f195 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -119,6 +119,7 @@ apps/browser/src/autofill @bitwarden/team-autofill-dev apps/desktop/src/autofill @bitwarden/team-autofill-dev libs/common/src/autofill @bitwarden/team-autofill-dev apps/desktop/macos/autofill-extension @bitwarden/team-autofill-dev +apps/desktop/src/app/components/fido2placeholder.component.ts @bitwarden/team-autofill-dev apps/desktop/desktop_native/windows-plugin-authenticator @bitwarden/team-autofill-dev # DuckDuckGo integration apps/desktop/native-messaging-test-runner @bitwarden/team-autofill-dev diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 3a30629b444..36e267c9355 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -57,6 +57,7 @@ import { TwoFactorComponentV1 } from "../auth/two-factor-v1.component"; import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component"; import { VaultComponent } from "../vault/app/vault/vault.component"; +import { Fido2PlaceholderComponent } from "./components/fido2placeholder.component"; import { SendComponent } from "./tools/send/send.component"; /** @@ -177,6 +178,10 @@ const routes: Routes = [ component: RemovePasswordComponent, canActivate: [authGuard], }, + { + path: "passkeys", + component: Fido2PlaceholderComponent, + }, { path: "", component: AnonLayoutWrapperComponent, diff --git a/apps/desktop/src/app/components/fido2placeholder.component.ts b/apps/desktop/src/app/components/fido2placeholder.component.ts new file mode 100644 index 00000000000..b3302d63241 --- /dev/null +++ b/apps/desktop/src/app/components/fido2placeholder.component.ts @@ -0,0 +1,36 @@ +import { Component } from "@angular/core"; +import { Router } from "@angular/router"; + +import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; + +@Component({ + standalone: true, + template: ` +
+

Select your passkey

+
+ +
+ `, +}) +export class Fido2PlaceholderComponent { + constructor( + private readonly desktopSettingsService: DesktopSettingsService, + private readonly router: Router, + ) {} + + async closeModal() { + await this.router.navigate(["/"]); + await this.desktopSettingsService.setInModalMode(false); + } +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4e167f30ec8..e4894b159fe 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -284,6 +284,8 @@ export class Main { this.migrationRunner.run().then( async () => { await this.toggleHardwareAcceleration(); + // Reset modal mode to make sure main window is displayed correctly + await this.desktopSettingsService.resetInModalMode(); await this.windowMain.init(); await this.i18nService.init(); await this.messagingMain.init(); diff --git a/apps/desktop/src/main/tray.main.ts b/apps/desktop/src/main/tray.main.ts index 9fa7fe6143f..e63e2a00c85 100644 --- a/apps/desktop/src/main/tray.main.ts +++ b/apps/desktop/src/main/tray.main.ts @@ -1,6 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import * as path from "path"; +import * as url from "url"; import { app, BrowserWindow, Menu, MenuItemConstructorOptions, nativeImage, Tray } from "electron"; import { firstValueFrom } from "rxjs"; @@ -9,6 +10,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { BiometricStateService, BiometricsService } from "@bitwarden/key-management"; import { DesktopSettingsService } from "../platform/services/desktop-settings.service"; +import { cleanUserAgent, isDev } from "../utils"; import { WindowMain } from "./window.main"; @@ -49,6 +51,11 @@ export class TrayMain { label: this.i18nService.t("showHide"), click: () => this.toggleWindow(), }, + { + visible: isDev(), + label: "Fake Popup", + click: () => this.fakePopup(), + }, { type: "separator" }, { label: this.i18nService.t("exit"), @@ -190,7 +197,7 @@ export class TrayMain { this.hideDock(); } } else { - this.windowMain.win.show(); + this.windowMain.show(); if (this.isDarwin()) { this.showDock(); } @@ -203,4 +210,38 @@ export class TrayMain { this.windowMain.win.close(); } } + + /** + * This method is used to test modal behavior during development and could be removed in the future. + * @returns + */ + private async fakePopup() { + if (this.windowMain.win == null || this.windowMain.win.isDestroyed()) { + await this.windowMain.createWindow("modal-app"); + return; + } + + // Restyle existing + const existingWin = this.windowMain.win; + + await this.desktopSettingsService.setInModalMode(true); + await existingWin.loadURL( + url.format({ + protocol: "file:", + //pathname: `${__dirname}/index.html`, + pathname: path.join(__dirname, "/index.html"), + slashes: true, + hash: "/passkeys", + query: { + redirectUrl: "/passkeys", + }, + }), + { + userAgent: cleanUserAgent(existingWin.webContents.userAgent), + }, + ); + existingWin.once("ready-to-show", () => { + existingWin.show(); + }); + } } diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index 17f74b78d4c..ca154400ff5 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -5,7 +5,7 @@ import * as path from "path"; import * as url from "url"; import { app, BrowserWindow, ipcMain, nativeTheme, screen, session } from "electron"; -import { firstValueFrom } from "rxjs"; +import { concatMap, firstValueFrom, pairwise } from "rxjs"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; @@ -14,6 +14,7 @@ import { processisolations } from "@bitwarden/desktop-napi"; import { BiometricStateService } from "@bitwarden/key-management"; import { WindowState } from "../platform/models/domain/window-state"; +import { applyMainWindowStyles, applyPopupModalStyles } from "../platform/popup-modal-styles"; import { DesktopSettingsService } from "../platform/services/desktop-settings.service"; import { cleanUserAgent, isDev, isLinux, isMac, isMacAppStore, isWindows } from "../utils"; @@ -77,6 +78,24 @@ export class WindowMain { } }); + this.desktopSettingsService.inModalMode$ + .pipe( + pairwise(), + concatMap(async ([lastValue, newValue]) => { + if (lastValue && !newValue) { + // Reset the window state to the main window state + applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]); + // Because modal is used in front of another app, UX wise it makes sense to hide the main window when leaving modal mode. + this.win.hide(); + } else if (!lastValue && newValue) { + // Apply the popup modal styles + applyPopupModalStyles(this.win); + this.win.show(); + } + }), + ) + .subscribe(); + this.desktopSettingsService.preventScreenshots$.subscribe((prevent) => { if (this.win == null) { return; @@ -182,7 +201,20 @@ export class WindowMain { }); } - async createWindow(): Promise { + /// Show the window with main window styles + show() { + if (this.win != null) { + applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]); + this.win.show(); + } + } + + /** + * Creates the main window. The template argument is used to determine the styling of the window and what url will be loaded. + * When the template is "modal-app", the window will be styled as a modal and the passkeys page will be loaded. + * TODO: We might want to refactor the template argument to accomodate more target pages, e.g. ssh-agent. + */ + async createWindow(template: "full-app" | "modal-app" = "full-app"): Promise { this.windowStates[mainWindowSizeKey] = await this.getWindowState( this.defaultWidth, this.defaultHeight, @@ -216,6 +248,12 @@ export class WindowMain { }, }); + if (template === "modal-app") { + applyPopupModalStyles(this.win); + } else { + applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]); + } + this.win.webContents.on("dom-ready", () => { this.win.webContents.zoomFactor = this.windowStates[mainWindowSizeKey].zoomFactor ?? 1.0; }); @@ -225,21 +263,41 @@ export class WindowMain { } // Show it later since it might need to be maximized. - this.win.show(); + // use once event to avoid flash on unstyled content. + this.win.once("ready-to-show", () => { + this.win.show(); + }); - // and load the index.html of the app. - // 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.win.loadURL( - url.format({ - protocol: "file:", - pathname: path.join(__dirname, "/index.html"), - slashes: true, - }), - { - userAgent: cleanUserAgent(this.win.webContents.userAgent), - }, - ); + if (template === "full-app") { + // and load the index.html of the app. + // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. + void this.win.loadURL( + url.format({ + protocol: "file:", + pathname: path.join(__dirname, "/index.html"), + slashes: true, + }), + { + userAgent: cleanUserAgent(this.win.webContents.userAgent), + }, + ); + } else { + // we're in modal mode - load the passkeys page + await this.win.loadURL( + url.format({ + protocol: "file:", + pathname: path.join(__dirname, "/index.html"), + slashes: true, + hash: "/passkeys", + query: { + redirectUrl: "/passkeys", + }, + }), + { + userAgent: cleanUserAgent(this.win.webContents.userAgent), + }, + ); + } // Open the DevTools. if (isDev()) { @@ -336,6 +394,12 @@ export class WindowMain { return; } + const inModalMode = await firstValueFrom(this.desktopSettingsService.inModalMode$); + + if (inModalMode) { + return; + } + try { const bounds = win.getBounds(); @@ -346,9 +410,14 @@ export class WindowMain { } } - this.windowStates[configKey].isMaximized = win.isMaximized(); + // We treat fullscreen as maximized (would be even better to store isFullscreen as its own flag). + this.windowStates[configKey].isMaximized = win.isMaximized() || win.isFullScreen(); this.windowStates[configKey].displayBounds = screen.getDisplayMatching(bounds).bounds; + // Maybe store these as well? + // win.isFocused(); + // win.isVisible(); + if (!win.isMaximized() && !win.isMinimized() && !win.isFullScreen()) { this.windowStates[configKey].x = bounds.x; this.windowStates[configKey].y = bounds.y; diff --git a/apps/desktop/src/platform/popup-modal-styles.ts b/apps/desktop/src/platform/popup-modal-styles.ts new file mode 100644 index 00000000000..9c3f06b34bf --- /dev/null +++ b/apps/desktop/src/platform/popup-modal-styles.ts @@ -0,0 +1,52 @@ +import { BrowserWindow } from "electron"; + +import { WindowState } from "./models/domain/window-state"; + +// change as needed, however limited by mainwindow minimum size +const popupWidth = 680; +const popupHeight = 500; + +export function applyPopupModalStyles(window: BrowserWindow) { + window.unmaximize(); + window.setSize(popupWidth, popupHeight); + window.center(); + window.setWindowButtonVisibility?.(false); + window.setMenuBarVisibility?.(false); + window.setResizable(false); + window.setAlwaysOnTop(true); + + // Adjusting from full screen is a bit more hassle + if (window.isFullScreen()) { + window.setFullScreen(false); + window.once("leave-full-screen", () => { + window.setSize(popupWidth, popupHeight); + window.center(); + }); + } +} + +export function applyMainWindowStyles(window: BrowserWindow, existingWindowState: WindowState) { + window.setMinimumSize(680, 500); + + // need to guard against null/undefined values + + if (existingWindowState?.width && existingWindowState?.height) { + window.setSize(existingWindowState.width, existingWindowState.height); + } + + if (existingWindowState?.x && existingWindowState?.y) { + window.setPosition(existingWindowState.x, existingWindowState.y); + } + + window.setWindowButtonVisibility?.(true); + window.setMenuBarVisibility?.(true); + window.setResizable(true); + window.setAlwaysOnTop(false); + + // We're currently not recovering the maximized state, mostly due to conflicts with hiding the window. + // window.setFullScreen(existingWindowState.isMaximized); + + // if (existingWindowState.isMaximized) { + // window.maximize(); + // } +} diff --git a/apps/desktop/src/platform/services/desktop-settings.service.ts b/apps/desktop/src/platform/services/desktop-settings.service.ts index f0d5d124de2..efac0cda252 100644 --- a/apps/desktop/src/platform/services/desktop-settings.service.ts +++ b/apps/desktop/src/platform/services/desktop-settings.service.ts @@ -75,6 +75,10 @@ const MINIMIZE_ON_COPY = new UserKeyDefinition(DESKTOP_SETTINGS_DISK, " clearOn: [], // User setting, no need to clear }); +const IN_MODAL_MODE = new KeyDefinition(DESKTOP_SETTINGS_DISK, "inModalMode", { + deserializer: (b) => b, +}); + const PREVENT_SCREENSHOTS = new KeyDefinition( DESKTOP_SETTINGS_DISK, "preventScreenshots", @@ -170,6 +174,10 @@ export class DesktopSettingsService { */ minimizeOnCopy$ = this.minimizeOnCopyState.state$.pipe(map(Boolean)); + private readonly inModalModeState = this.stateProvider.getGlobal(IN_MODAL_MODE); + + inModalMode$ = this.inModalModeState.state$.pipe(map(Boolean)); + constructor(private stateProvider: StateProvider) { this.window$ = this.windowState.state$.pipe( map((window) => @@ -178,6 +186,14 @@ export class DesktopSettingsService { ); } + /** + * This is used to clear the setting on application start to make sure we don't end up + * stuck in modal mode if the application is force-closed in modal mode. + */ + async resetInModalMode() { + await this.inModalModeState.update(() => false); + } + async setHardwareAcceleration(enabled: boolean) { await this.hwState.update(() => enabled); } @@ -286,6 +302,14 @@ export class DesktopSettingsService { await this.stateProvider.getUser(userId, MINIMIZE_ON_COPY).update(() => value); } + /** + * Sets the modal mode of the application. Setting this changes the windows-size and other properties. + * @param value `true` if the application is in modal mode, `false` if it is not. + */ + async setInModalMode(value: boolean) { + await this.inModalModeState.update(() => value); + } + /** * Sets the setting for whether or not the screenshot protection is enabled. * @param value `true` if the screenshot protection is enabled, `false` if it is not. From 5cd47ac90717a40346c057487a4e764f73a8fe7b Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:06:44 +0100 Subject: [PATCH 20/35] [PM-18243] Improve type safety in decryption (#12885) * Improve decrypt failure logging * Rename decryptcontext to decrypttrace * Improve docs * PM-16984: Improving type safety of decryption * Improving type safety of decryption --------- Co-authored-by: Bernd Schoolmann --- .../common/collections/models/collection.ts | 7 +-- .../src/platform/models/domain/domain-base.ts | 57 ++++++++++--------- .../src/platform/models/domain/enc-string.ts | 2 +- .../tools/send/models/domain/send-access.ts | 9 +-- .../src/tools/send/models/domain/send-file.ts | 8 +-- .../src/tools/send/models/domain/send-text.ts | 7 +-- .../src/tools/send/models/domain/send.ts | 10 +--- .../src/vault/models/domain/attachment.ts | 7 +-- libs/common/src/vault/models/domain/card.ts | 12 +--- libs/common/src/vault/models/domain/cipher.ts | 8 +-- .../vault/models/domain/fido2-credential.ts | 47 +++++++-------- libs/common/src/vault/models/domain/field.ts | 8 +-- libs/common/src/vault/models/domain/folder.ts | 8 +-- .../src/vault/models/domain/identity.ts | 43 +++++++------- .../src/vault/models/domain/login-uri.ts | 7 +-- libs/common/src/vault/models/domain/login.ts | 9 +-- .../src/vault/models/domain/password.ts | 7 +-- .../common/src/vault/models/domain/ssh-key.ts | 9 +-- 18 files changed, 111 insertions(+), 154 deletions(-) diff --git a/libs/admin-console/src/common/collections/models/collection.ts b/libs/admin-console/src/common/collections/models/collection.ts index f14ccb20141..5b6f1a6fb7a 100644 --- a/libs/admin-console/src/common/collections/models/collection.ts +++ b/libs/admin-console/src/common/collections/models/collection.ts @@ -39,11 +39,10 @@ export class Collection extends Domain { } decrypt(orgKey: OrgKey): Promise { - return this.decryptObj( + return this.decryptObj( + this, new CollectionView(this), - { - name: null, - }, + ["name"], this.organizationId, orgKey, ); diff --git a/libs/common/src/platform/models/domain/domain-base.ts b/libs/common/src/platform/models/domain/domain-base.ts index 5aa79946653..11d0193accc 100644 --- a/libs/common/src/platform/models/domain/domain-base.ts +++ b/libs/common/src/platform/models/domain/domain-base.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { ConditionalExcept, ConditionalKeys, Constructor } from "type-fest"; import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service"; @@ -15,6 +13,19 @@ export type DecryptedObject< TDecryptedKeys extends EncStringKeys, > = Record & Omit; +// extracts shared keys from the domain and view types +type EncryptableKeys = (keyof D & + ConditionalKeys) & + (keyof V & ConditionalKeys); + +type DomainEncryptableKeys = { + [key in ConditionalKeys]: EncString | null; +}; + +type ViewEncryptableKeys = { + [key in ConditionalKeys]: string | null; +}; + // https://contributing.bitwarden.com/architecture/clients/data-model#domain export default class Domain { protected buildDomainModel( @@ -37,6 +48,7 @@ export default class Domain { } } } + protected buildDataModel( domain: D, dataObj: any, @@ -58,31 +70,24 @@ export default class Domain { } } - protected async decryptObj( - viewModel: T, - map: any, - orgId: string, - key: SymmetricCryptoKey = null, + protected async decryptObj( + domain: DomainEncryptableKeys, + viewModel: ViewEncryptableKeys, + props: EncryptableKeys[], + orgId: string | null, + key: SymmetricCryptoKey | null = null, objectContext: string = "No Domain Context", - ): Promise { - const self: any = this; - - for (const prop in map) { - // eslint-disable-next-line - if (!map.hasOwnProperty(prop)) { - continue; - } - - const mapProp = map[prop] || prop; - if (self[mapProp]) { - (viewModel as any)[prop] = await self[mapProp].decrypt( + ): Promise { + for (const prop of props) { + viewModel[prop] = + (await domain[prop]?.decrypt( orgId, key, - `Property: ${prop}; ObjectContext: ${objectContext}`, - ); - } + `Property: ${prop as string}; ObjectContext: ${objectContext}`, + )) ?? null; } - return viewModel; + + return viewModel as V; } /** @@ -111,7 +116,7 @@ export default class Domain { const decryptedObjects = []; for (const prop of encryptedProperties) { - const value = (this as any)[prop] as EncString; + const value = this[prop] as EncString; const decrypted = await this.decryptProperty( prop, value, @@ -138,11 +143,9 @@ export default class Domain { encryptService: EncryptService, decryptTrace: string, ) { - let decrypted: string = null; + let decrypted: string | null = null; if (value) { decrypted = await value.decryptWithKey(key, encryptService, decryptTrace); - } else { - decrypted = null; } return { [propertyKey]: decrypted, diff --git a/libs/common/src/platform/models/domain/enc-string.ts b/libs/common/src/platform/models/domain/enc-string.ts index 360cb9bab46..4ea58a6809e 100644 --- a/libs/common/src/platform/models/domain/enc-string.ts +++ b/libs/common/src/platform/models/domain/enc-string.ts @@ -160,7 +160,7 @@ export class EncString implements Encrypted { async decrypt( orgId: string | null, - key: SymmetricCryptoKey = null, + key: SymmetricCryptoKey | null = null, context?: string, ): Promise { if (this.decryptedValue != null) { diff --git a/libs/common/src/tools/send/models/domain/send-access.ts b/libs/common/src/tools/send/models/domain/send-access.ts index dcc2d3ef426..588c4e84aa1 100644 --- a/libs/common/src/tools/send/models/domain/send-access.ts +++ b/libs/common/src/tools/send/models/domain/send-access.ts @@ -54,14 +54,7 @@ export class SendAccess extends Domain { async decrypt(key: SymmetricCryptoKey): Promise { const model = new SendAccessView(this); - await this.decryptObj( - model, - { - name: null, - }, - null, - key, - ); + await this.decryptObj(this, model, ["name"], null, key); switch (this.type) { case SendType.File: diff --git a/libs/common/src/tools/send/models/domain/send-file.ts b/libs/common/src/tools/send/models/domain/send-file.ts index 90e40f3959a..b8d0a265081 100644 --- a/libs/common/src/tools/send/models/domain/send-file.ts +++ b/libs/common/src/tools/send/models/domain/send-file.ts @@ -34,15 +34,13 @@ export class SendFile extends Domain { } async decrypt(key: SymmetricCryptoKey): Promise { - const view = await this.decryptObj( + return await this.decryptObj( + this, new SendFileView(this), - { - fileName: null, - }, + ["fileName"], null, key, ); - return view; } static fromJSON(obj: Jsonify) { diff --git a/libs/common/src/tools/send/models/domain/send-text.ts b/libs/common/src/tools/send/models/domain/send-text.ts index b17e3f769fb..df33e555896 100644 --- a/libs/common/src/tools/send/models/domain/send-text.ts +++ b/libs/common/src/tools/send/models/domain/send-text.ts @@ -30,11 +30,10 @@ export class SendText extends Domain { } decrypt(key: SymmetricCryptoKey): Promise { - return this.decryptObj( + return this.decryptObj( + this, new SendTextView(this), - { - text: null, - }, + ["text"], null, key, ); diff --git a/libs/common/src/tools/send/models/domain/send.ts b/libs/common/src/tools/send/models/domain/send.ts index c2390d439e7..f12a0010fab 100644 --- a/libs/common/src/tools/send/models/domain/send.ts +++ b/libs/common/src/tools/send/models/domain/send.ts @@ -87,15 +87,7 @@ export class Send extends Domain { // TODO: error? } - await this.decryptObj( - model, - { - name: null, - notes: null, - }, - null, - model.cryptoKey, - ); + await this.decryptObj(this, model, ["name", "notes"], null, model.cryptoKey); switch (this.type) { case SendType.File: diff --git a/libs/common/src/vault/models/domain/attachment.ts b/libs/common/src/vault/models/domain/attachment.ts index 4eee0307746..16f3adbe307 100644 --- a/libs/common/src/vault/models/domain/attachment.ts +++ b/libs/common/src/vault/models/domain/attachment.ts @@ -43,11 +43,10 @@ export class Attachment extends Domain { context = "No Cipher Context", encKey?: SymmetricCryptoKey, ): Promise { - const view = await this.decryptObj( + const view = await this.decryptObj( + this, new AttachmentView(this), - { - fileName: null, - }, + ["fileName"], orgId, encKey, "DomainType: Attachment; " + context, diff --git a/libs/common/src/vault/models/domain/card.ts b/libs/common/src/vault/models/domain/card.ts index fccfe3f595b..3d73a8f527c 100644 --- a/libs/common/src/vault/models/domain/card.ts +++ b/libs/common/src/vault/models/domain/card.ts @@ -42,16 +42,10 @@ export class Card extends Domain { context = "No Cipher Context", encKey?: SymmetricCryptoKey, ): Promise { - return this.decryptObj( + return this.decryptObj( + this, new CardView(), - { - cardholderName: null, - brand: null, - number: null, - expMonth: null, - expYear: null, - code: null, - }, + ["cardholderName", "brand", "number", "expMonth", "expYear", "code"], orgId, encKey, "DomainType: Card; " + context, diff --git a/libs/common/src/vault/models/domain/cipher.ts b/libs/common/src/vault/models/domain/cipher.ts index 21538b87788..c08ec8a4ebc 100644 --- a/libs/common/src/vault/models/domain/cipher.ts +++ b/libs/common/src/vault/models/domain/cipher.ts @@ -154,12 +154,10 @@ export class Cipher extends Domain implements Decryptable { bypassValidation = false; } - await this.decryptObj( + await this.decryptObj( + this, model, - { - name: null, - notes: null, - }, + ["name", "notes"], this.organizationId, encKey, ); diff --git a/libs/common/src/vault/models/domain/fido2-credential.ts b/libs/common/src/vault/models/domain/fido2-credential.ts index 9aa2c753d7c..8b0082892e4 100644 --- a/libs/common/src/vault/models/domain/fido2-credential.ts +++ b/libs/common/src/vault/models/domain/fido2-credential.ts @@ -52,41 +52,38 @@ export class Fido2Credential extends Domain { } async decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise { - const view = await this.decryptObj( + const view = await this.decryptObj( + this, new Fido2CredentialView(), - { - credentialId: null, - keyType: null, - keyAlgorithm: null, - keyCurve: null, - keyValue: null, - rpId: null, - userHandle: null, - userName: null, - rpName: null, - userDisplayName: null, - discoverable: null, - }, + [ + "credentialId", + "keyType", + "keyAlgorithm", + "keyCurve", + "keyValue", + "rpId", + "userHandle", + "userName", + "rpName", + "userDisplayName", + ], orgId, encKey, ); - const { counter } = await this.decryptObj( - { counter: "" }, + const { counter } = await this.decryptObj< + Fido2Credential, { - counter: null, - }, - orgId, - encKey, - ); + counter: string; + } + >(this, { counter: "" }, ["counter"], orgId, encKey); // Counter will end up as NaN if this fails view.counter = parseInt(counter); - const { discoverable } = await this.decryptObj( + const { discoverable } = await this.decryptObj( + this, { discoverable: "" }, - { - discoverable: null, - }, + ["discoverable"], orgId, encKey, ); diff --git a/libs/common/src/vault/models/domain/field.ts b/libs/common/src/vault/models/domain/field.ts index f836184da6a..c0f08a38bcc 100644 --- a/libs/common/src/vault/models/domain/field.ts +++ b/libs/common/src/vault/models/domain/field.ts @@ -35,12 +35,10 @@ export class Field extends Domain { } decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise { - return this.decryptObj( + return this.decryptObj( + this, new FieldView(this), - { - name: null, - value: null, - }, + ["name", "value"], orgId, encKey, ); diff --git a/libs/common/src/vault/models/domain/folder.ts b/libs/common/src/vault/models/domain/folder.ts index 65018e3cf08..8749a92fb65 100644 --- a/libs/common/src/vault/models/domain/folder.ts +++ b/libs/common/src/vault/models/domain/folder.ts @@ -40,13 +40,7 @@ export class Folder extends Domain { } decrypt(): Promise { - return this.decryptObj( - new FolderView(this), - { - name: null, - }, - null, - ); + return this.decryptObj(this, new FolderView(this), ["name"], null); } async decryptWithKey( diff --git a/libs/common/src/vault/models/domain/identity.ts b/libs/common/src/vault/models/domain/identity.ts index 570e6c0b4d5..5d8c20ef2b3 100644 --- a/libs/common/src/vault/models/domain/identity.ts +++ b/libs/common/src/vault/models/domain/identity.ts @@ -66,28 +66,29 @@ export class Identity extends Domain { context: string = "No Cipher Context", encKey?: SymmetricCryptoKey, ): Promise { - return this.decryptObj( + return this.decryptObj( + this, new IdentityView(), - { - title: null, - firstName: null, - middleName: null, - lastName: null, - address1: null, - address2: null, - address3: null, - city: null, - state: null, - postalCode: null, - country: null, - company: null, - email: null, - phone: null, - ssn: null, - username: null, - passportNumber: null, - licenseNumber: null, - }, + [ + "title", + "firstName", + "middleName", + "lastName", + "address1", + "address2", + "address3", + "city", + "state", + "postalCode", + "country", + "company", + "email", + "phone", + "ssn", + "username", + "passportNumber", + "licenseNumber", + ], orgId, encKey, "DomainType: Identity; " + context, diff --git a/libs/common/src/vault/models/domain/login-uri.ts b/libs/common/src/vault/models/domain/login-uri.ts index 36782a81502..883f8c9a616 100644 --- a/libs/common/src/vault/models/domain/login-uri.ts +++ b/libs/common/src/vault/models/domain/login-uri.ts @@ -38,11 +38,10 @@ export class LoginUri extends Domain { context: string = "No Cipher Context", encKey?: SymmetricCryptoKey, ): Promise { - return this.decryptObj( + return this.decryptObj( + this, new LoginUriView(this), - { - uri: null, - }, + ["uri"], orgId, encKey, context, diff --git a/libs/common/src/vault/models/domain/login.ts b/libs/common/src/vault/models/domain/login.ts index f9a85cd818e..b29b42bf3de 100644 --- a/libs/common/src/vault/models/domain/login.ts +++ b/libs/common/src/vault/models/domain/login.ts @@ -58,13 +58,10 @@ export class Login extends Domain { context: string = "No Cipher Context", encKey?: SymmetricCryptoKey, ): Promise { - const view = await this.decryptObj( + const view = await this.decryptObj( + this, new LoginView(this), - { - username: null, - password: null, - totp: null, - }, + ["username", "password", "totp"], orgId, encKey, `DomainType: Login; ${context}`, diff --git a/libs/common/src/vault/models/domain/password.ts b/libs/common/src/vault/models/domain/password.ts index 48063f495f0..8573c224416 100644 --- a/libs/common/src/vault/models/domain/password.ts +++ b/libs/common/src/vault/models/domain/password.ts @@ -25,11 +25,10 @@ export class Password extends Domain { } decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise { - return this.decryptObj( + return this.decryptObj( + this, new PasswordHistoryView(this), - { - password: null, - }, + ["password"], orgId, encKey, "DomainType: PasswordHistory", diff --git a/libs/common/src/vault/models/domain/ssh-key.ts b/libs/common/src/vault/models/domain/ssh-key.ts index b4df172e543..f32a1a913ca 100644 --- a/libs/common/src/vault/models/domain/ssh-key.ts +++ b/libs/common/src/vault/models/domain/ssh-key.ts @@ -36,13 +36,10 @@ export class SshKey extends Domain { context = "No Cipher Context", encKey?: SymmetricCryptoKey, ): Promise { - return this.decryptObj( + return this.decryptObj( + this, new SshKeyView(), - { - privateKey: null, - publicKey: null, - keyFingerprint: null, - }, + ["privateKey", "publicKey", "keyFingerprint"], orgId, encKey, "DomainType: SshKey; " + context, From 9683779dbf6f3ffde6860286f96c92abe63b9bd2 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Tue, 11 Mar 2025 14:20:02 +0100 Subject: [PATCH 21/35] [PM-17984] Remove AES128CBC-HMAC encryption (#13304) * Remove AES128CBC-HMAC encryption * Increase test coverage --- .../crypto/abstractions/encrypt.service.ts | 1 - .../encrypt.service.implementation.ts | 19 ------ .../crypto/services/encrypt.service.spec.ts | 62 +++++++++---------- .../platform/enums/encryption-type.enum.ts | 7 +-- .../models/domain/enc-array-buffer.spec.ts | 39 ++++++------ .../models/domain/enc-array-buffer.ts | 1 - .../platform/models/domain/enc-string.spec.ts | 2 - .../src/platform/models/domain/enc-string.ts | 6 +- .../domain/symmetric-crypto-key.spec.ts | 15 ----- .../models/domain/symmetric-crypto-key.ts | 3 - 10 files changed, 50 insertions(+), 105 deletions(-) diff --git a/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts b/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts index 484327bcd27..f7f064f5251 100644 --- a/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts +++ b/libs/common/src/key-management/crypto/abstractions/encrypt.service.ts @@ -36,7 +36,6 @@ export abstract class EncryptService { ): Promise; abstract rsaEncrypt(data: Uint8Array, publicKey: Uint8Array): Promise; abstract rsaDecrypt(data: EncString, privateKey: Uint8Array): Promise; - abstract resolveLegacyKey(key: SymmetricCryptoKey, encThing: Encrypted): SymmetricCryptoKey; /** * @deprecated Replaced by BulkEncryptService, remove once the feature is tested and the featureflag PM-4154-multi-worker-encryption-service is removed * @param items The items to decrypt diff --git a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts index 8a001886837..d426340c277 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts @@ -78,8 +78,6 @@ export class EncryptServiceImplementation implements EncryptService { throw new Error("No key provided for decryption."); } - key = this.resolveLegacyKey(key, encString); - // DO NOT REMOVE OR MOVE. This prevents downgrade to mac-less CBC, which would compromise integrity and confidentiality. if (key.macKey != null && encString?.mac == null) { this.logService.error( @@ -145,8 +143,6 @@ export class EncryptServiceImplementation implements EncryptService { throw new Error("Nothing provided for decryption."); } - key = this.resolveLegacyKey(key, encThing); - // DO NOT REMOVE OR MOVE. This prevents downgrade to mac-less CBC, which would compromise integrity and confidentiality. if (key.macKey != null && encThing.macBytes == null) { this.logService.error( @@ -298,19 +294,4 @@ export class EncryptServiceImplementation implements EncryptService { this.logService.error(msg); } } - - /** - * Transform into new key for the old encrypt-then-mac scheme if required, otherwise return the current key unchanged - * @param encThing The encrypted object (e.g. encString or encArrayBuffer) that you want to decrypt - */ - resolveLegacyKey(key: SymmetricCryptoKey, encThing: Encrypted): SymmetricCryptoKey { - if ( - encThing.encryptionType === EncryptionType.AesCbc128_HmacSha256_B64 && - key.encType === EncryptionType.AesCbc256_B64 - ) { - return new SymmetricCryptoKey(key.key, EncryptionType.AesCbc128_HmacSha256_B64); - } - - return key; - } } diff --git a/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts b/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts index cff695f4829..3ce0d5883d2 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts @@ -325,6 +325,25 @@ describe("EncryptService", () => { }); }); + describe("decryptToUtf8", () => { + it("throws if no key is provided", () => { + return expect(encryptService.decryptToUtf8(null, null)).rejects.toThrow( + "No key provided for decryption.", + ); + }); + it("returns null if key is mac key but encstring has no mac", async () => { + const key = new SymmetricCryptoKey( + makeStaticByteArray(64, 0), + EncryptionType.AesCbc256_HmacSha256_B64, + ); + const encString = new EncString(EncryptionType.AesCbc256_B64, "data"); + + const actual = await encryptService.decryptToUtf8(encString, key); + expect(actual).toBeNull(); + expect(logService.error).toHaveBeenCalled(); + }); + }); + describe("rsa", () => { const data = makeStaticByteArray(10, 100); const encryptedData = makeStaticByteArray(10, 150); @@ -370,17 +389,16 @@ describe("EncryptService", () => { return expect(encryptService.rsaDecrypt(encString, null)).rejects.toThrow("No private key"); }); - it.each([ - EncryptionType.AesCbc256_B64, - EncryptionType.AesCbc128_HmacSha256_B64, - EncryptionType.AesCbc256_HmacSha256_B64, - ])("throws if encryption type is %s", async (encType) => { - encString.encryptionType = encType; + it.each([EncryptionType.AesCbc256_B64, EncryptionType.AesCbc256_HmacSha256_B64])( + "throws if encryption type is %s", + async (encType) => { + encString.encryptionType = encType; - await expect(encryptService.rsaDecrypt(encString, privateKey)).rejects.toThrow( - "Invalid encryption type", - ); - }); + await expect(encryptService.rsaDecrypt(encString, privateKey)).rejects.toThrow( + "Invalid encryption type", + ); + }, + ); it("decrypts data with provided key", async () => { cryptoFunctionService.rsaDecrypt.mockResolvedValue(data); @@ -398,30 +416,6 @@ describe("EncryptService", () => { }); }); - describe("resolveLegacyKey", () => { - it("creates a legacy key if required", async () => { - const key = new SymmetricCryptoKey(makeStaticByteArray(32), EncryptionType.AesCbc256_B64); - const encString = mock(); - encString.encryptionType = EncryptionType.AesCbc128_HmacSha256_B64; - - const actual = encryptService.resolveLegacyKey(key, encString); - - const expected = new SymmetricCryptoKey(key.key, EncryptionType.AesCbc128_HmacSha256_B64); - expect(actual).toEqual(expected); - }); - - it("does not create a legacy key if not required", async () => { - const encType = EncryptionType.AesCbc256_HmacSha256_B64; - const key = new SymmetricCryptoKey(makeStaticByteArray(64), encType); - const encString = mock(); - encString.encryptionType = encType; - - const actual = encryptService.resolveLegacyKey(key, encString); - - expect(actual).toEqual(key); - }); - }); - describe("hash", () => { it("hashes a string and returns b64", async () => { cryptoFunctionService.hash.mockResolvedValue(Uint8Array.from([1, 2, 3])); diff --git a/libs/common/src/platform/enums/encryption-type.enum.ts b/libs/common/src/platform/enums/encryption-type.enum.ts index a0ffe679279..fd484dc2fdf 100644 --- a/libs/common/src/platform/enums/encryption-type.enum.ts +++ b/libs/common/src/platform/enums/encryption-type.enum.ts @@ -1,6 +1,6 @@ export enum EncryptionType { AesCbc256_B64 = 0, - AesCbc128_HmacSha256_B64 = 1, + // Type 1 was the unused and removed AesCbc128_HmacSha256_B64 AesCbc256_HmacSha256_B64 = 2, Rsa2048_OaepSha256_B64 = 3, Rsa2048_OaepSha1_B64 = 4, @@ -17,12 +17,10 @@ export function encryptionTypeToString(encryptionType: EncryptionType): string { } /** The expected number of parts to a serialized EncString of the given encryption type. - * For example, an EncString of type AesCbc256_B64 will have 2 parts, and an EncString of type - * AesCbc128_HmacSha256_B64 will have 3 parts. + * For example, an EncString of type AesCbc256_B64 will have 2 parts * * Example of annotated serialized EncStrings: * 0.iv|data - * 1.iv|data|mac * 2.iv|data|mac * 3.data * 4.data @@ -33,7 +31,6 @@ export function encryptionTypeToString(encryptionType: EncryptionType): string { */ export const EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE = { [EncryptionType.AesCbc256_B64]: 2, - [EncryptionType.AesCbc128_HmacSha256_B64]: 3, [EncryptionType.AesCbc256_HmacSha256_B64]: 3, [EncryptionType.Rsa2048_OaepSha256_B64]: 1, [EncryptionType.Rsa2048_OaepSha1_B64]: 1, diff --git a/libs/common/src/platform/models/domain/enc-array-buffer.spec.ts b/libs/common/src/platform/models/domain/enc-array-buffer.spec.ts index 45a45ffe087..de0fea58e36 100644 --- a/libs/common/src/platform/models/domain/enc-array-buffer.spec.ts +++ b/libs/common/src/platform/models/domain/enc-array-buffer.spec.ts @@ -5,28 +5,28 @@ import { EncArrayBuffer } from "./enc-array-buffer"; describe("encArrayBuffer", () => { describe("parses the buffer", () => { - test.each([ - [EncryptionType.AesCbc128_HmacSha256_B64, "AesCbc128_HmacSha256_B64"], - [EncryptionType.AesCbc256_HmacSha256_B64, "AesCbc256_HmacSha256_B64"], - ])("with %c%s", (encType: EncryptionType) => { - const iv = makeStaticByteArray(16, 10); - const mac = makeStaticByteArray(32, 20); - // We use the minimum data length of 1 to test the boundary of valid lengths - const data = makeStaticByteArray(1, 100); + test.each([[EncryptionType.AesCbc256_HmacSha256_B64, "AesCbc256_HmacSha256_B64"]])( + "with %c%s", + (encType: EncryptionType) => { + const iv = makeStaticByteArray(16, 10); + const mac = makeStaticByteArray(32, 20); + // We use the minimum data length of 1 to test the boundary of valid lengths + const data = makeStaticByteArray(1, 100); - const array = new Uint8Array(1 + iv.byteLength + mac.byteLength + data.byteLength); - array.set([encType]); - array.set(iv, 1); - array.set(mac, 1 + iv.byteLength); - array.set(data, 1 + iv.byteLength + mac.byteLength); + const array = new Uint8Array(1 + iv.byteLength + mac.byteLength + data.byteLength); + array.set([encType]); + array.set(iv, 1); + array.set(mac, 1 + iv.byteLength); + array.set(data, 1 + iv.byteLength + mac.byteLength); - const actual = new EncArrayBuffer(array); + const actual = new EncArrayBuffer(array); - expect(actual.encryptionType).toEqual(encType); - expect(actual.ivBytes).toEqualBuffer(iv); - expect(actual.macBytes).toEqualBuffer(mac); - expect(actual.dataBytes).toEqualBuffer(data); - }); + expect(actual.encryptionType).toEqual(encType); + expect(actual.ivBytes).toEqualBuffer(iv); + expect(actual.macBytes).toEqualBuffer(mac); + expect(actual.dataBytes).toEqualBuffer(data); + }, + ); it("with AesCbc256_B64", () => { const encType = EncryptionType.AesCbc256_B64; @@ -50,7 +50,6 @@ describe("encArrayBuffer", () => { describe("throws if the buffer has an invalid length", () => { test.each([ - [EncryptionType.AesCbc128_HmacSha256_B64, 50, "AesCbc128_HmacSha256_B64"], [EncryptionType.AesCbc256_HmacSha256_B64, 50, "AesCbc256_HmacSha256_B64"], [EncryptionType.AesCbc256_B64, 18, "AesCbc256_B64"], ])("with %c%c%s", (encType: EncryptionType, minLength: number) => { diff --git a/libs/common/src/platform/models/domain/enc-array-buffer.ts b/libs/common/src/platform/models/domain/enc-array-buffer.ts index 305504f57b7..8b69cb347ba 100644 --- a/libs/common/src/platform/models/domain/enc-array-buffer.ts +++ b/libs/common/src/platform/models/domain/enc-array-buffer.ts @@ -20,7 +20,6 @@ export class EncArrayBuffer implements Encrypted { const encType = encBytes[0]; switch (encType) { - case EncryptionType.AesCbc128_HmacSha256_B64: case EncryptionType.AesCbc256_HmacSha256_B64: { const minimumLength = ENC_TYPE_LENGTH + IV_LENGTH + MAC_LENGTH + MIN_DATA_LENGTH; if (encBytes.length < minimumLength) { diff --git a/libs/common/src/platform/models/domain/enc-string.spec.ts b/libs/common/src/platform/models/domain/enc-string.spec.ts index 3b2586fc22f..c3f257d442a 100644 --- a/libs/common/src/platform/models/domain/enc-string.spec.ts +++ b/libs/common/src/platform/models/domain/enc-string.spec.ts @@ -60,9 +60,7 @@ describe("EncString", () => { const cases = [ "aXY=|Y3Q=", // AesCbc256_B64 w/out header - "aXY=|Y3Q=|cnNhQ3Q=", // AesCbc128_HmacSha256_B64 w/out header "0.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==", // AesCbc256_B64 with header - "1.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==", // AesCbc128_HmacSha256_B64 "2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==", // AesCbc256_HmacSha256_B64 "3.QmFzZTY0UGFydA==", // Rsa2048_OaepSha256_B64 "4.QmFzZTY0UGFydA==", // Rsa2048_OaepSha1_B64 diff --git a/libs/common/src/platform/models/domain/enc-string.ts b/libs/common/src/platform/models/domain/enc-string.ts index 4ea58a6809e..b0b03e0fb3c 100644 --- a/libs/common/src/platform/models/domain/enc-string.ts +++ b/libs/common/src/platform/models/domain/enc-string.ts @@ -89,7 +89,6 @@ export class EncString implements Encrypted { } switch (encType) { - case EncryptionType.AesCbc128_HmacSha256_B64: case EncryptionType.AesCbc256_HmacSha256_B64: this.iv = encPieces[0]; this.data = encPieces[1]; @@ -132,10 +131,7 @@ export class EncString implements Encrypted { } } else { encPieces = encryptedString.split("|"); - encType = - encPieces.length === 3 - ? EncryptionType.AesCbc128_HmacSha256_B64 - : EncryptionType.AesCbc256_B64; + encType = EncryptionType.AesCbc256_B64; } return { diff --git a/libs/common/src/platform/models/domain/symmetric-crypto-key.spec.ts b/libs/common/src/platform/models/domain/symmetric-crypto-key.spec.ts index e4c43264eaf..58c902ebab6 100644 --- a/libs/common/src/platform/models/domain/symmetric-crypto-key.spec.ts +++ b/libs/common/src/platform/models/domain/symmetric-crypto-key.spec.ts @@ -27,21 +27,6 @@ describe("SymmetricCryptoKey", () => { }); }); - it("AesCbc128_HmacSha256_B64", () => { - const key = makeStaticByteArray(32); - const cryptoKey = new SymmetricCryptoKey(key, EncryptionType.AesCbc128_HmacSha256_B64); - - expect(cryptoKey).toEqual({ - encKey: key.slice(0, 16), - encKeyB64: "AAECAwQFBgcICQoLDA0ODw==", - encType: 1, - key: key, - keyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", - macKey: key.slice(16, 32), - macKeyB64: "EBESExQVFhcYGRobHB0eHw==", - }); - }); - it("AesCbc256_HmacSha256_B64", () => { const key = makeStaticByteArray(64); const cryptoKey = new SymmetricCryptoKey(key); diff --git a/libs/common/src/platform/models/domain/symmetric-crypto-key.ts b/libs/common/src/platform/models/domain/symmetric-crypto-key.ts index eab4c7b2114..372b869fd9c 100644 --- a/libs/common/src/platform/models/domain/symmetric-crypto-key.ts +++ b/libs/common/src/platform/models/domain/symmetric-crypto-key.ts @@ -38,9 +38,6 @@ export class SymmetricCryptoKey { if (encType === EncryptionType.AesCbc256_B64 && key.byteLength === 32) { this.encKey = key; this.macKey = null; - } else if (encType === EncryptionType.AesCbc128_HmacSha256_B64 && key.byteLength === 32) { - this.encKey = key.slice(0, 16); - this.macKey = key.slice(16, 32); } else if (encType === EncryptionType.AesCbc256_HmacSha256_B64 && key.byteLength === 64) { this.encKey = key.slice(0, 32); this.macKey = key.slice(32, 64); From ef06e9f03c292c7c4aaead7a9777fc9f5d7f1777 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Tue, 11 Mar 2025 14:42:10 +0100 Subject: [PATCH 22/35] [PM-15442]Upgrade modal additional instances (#13557) * display inline information error message * Add collection service * Refactor the code * Add a feature flag to the change * Add the modal pop for free org * Use custom error messages passed from the validator * Add the js document * Merge changes in main * Add the changes after file movement * remove these floating promises * Adding unit test and seprating the validation * fix the unit test request * Remove the conditional statment in test --- .../collection-dialog.component.html | 2 +- .../collection-dialog.component.ts | 62 +++++++++++++++ ...ree-org-collection-limit.validator.spec.ts | 78 +++++++++++++++++++ .../free-org-collection-limit.validator.ts | 44 +++++++++++ .../vault-header/vault-header.component.ts | 76 +++++++++++++++++- apps/web/src/locales/en/messages.json | 3 + 6 files changed, 261 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/app/admin-console/organizations/shared/validators/free-org-collection-limit.validator.spec.ts create mode 100644 apps/web/src/app/admin-console/organizations/shared/validators/free-org-collection-limit.validator.ts diff --git a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html index 61fc290f6fe..9188ba5ab96 100644 --- a/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html +++ b/apps/web/src/app/admin-console/organizations/shared/components/collection-dialog/collection-dialog.component.html @@ -124,7 +124,7 @@ buttonType="primary" [disabled]="loading || dialogReadonly" > - {{ "save" | i18n }} + {{ buttonDisplayName | i18n }}