mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 06:13:38 +00:00
[PM-1222] Store passkeys in Bitwarden vault (#4715)
* [EC-598] feat: scaffold content scripting * [EC-598] feat: load page script from content script * [EC-598] feat: succesfully intercept methods * [EC-598] feat: add better support for messaging * [EC-598] feat: implement calls to new service * [EC-598] feat: add ability to return responses * [EC-598] feat: half-implemented params mapping * [EC-598] feat: add b64 conversion * [EC-598] feat: half-implemented user interfacing * [EC-598] feat: initial working user verification * [EC-598] feat: center popup * [EC-598] feat: add basic cancel button * [EC-598] feat: confirm new credentials * [EC-598] feat: add cbor-redux npm package * [EC-598] feat: initial version of credential creation * [EC-598] feat: fully working credential creation * [EC-598] feat: fully working register and assert flow * [EC-598] feat: properly check for presence * [EC-598] feat: rudimentar error handling * [EC-598] feat: transparent passthrough of platform authenticators * [EC-598] feat: improve error handling * [EC-598] feat: use browser as fallback when vault does not contain requested credential * [EC-598] feat: add fido2Key to cipher * [EC-598] feat: successfully store passkeys in vault * [EC-598] feat: implement passwordless vault auth * [EC-598] feat: add basic support for managing passkeys * [EC-598] feat: show new cipher being added * [EC-598] feat: allow user to pick which credential to use * [EC-598] feat: differntiate between resident auth and 2fa * [EC-598] feat: add some padding to popout * [EC-598] feat: allow storage of more information * [EC-598] feat: show user name as sub title * [EC-598] feat: show all available data * [EC-598] chore: clean up console logs * [EC-598] feat: fix google issues Google does not like self-signed packed format. I've removed the attestation statement all-together untill further notice. We're don't really have any statements so * [EC-598] fix: temporarily remove origin check * [EC-598] fix: user interaction not being awaited sometimes Only one handler can return a response. That handler needs to return true to indicated it's intention to eventually do so. Our issue was that multiple handlers were returning truthy values, causing a race condition. * [EC-598] fix: messenger crashing The messenger is listening to all DOM communcation, most of which is formatted differently. We were not handling these cases properly which resulted in attempts to access undefined fields. * [EC-598] feat: add basic test-case for messenger * [EC-598] feat: add test for request/response * [EC-598] feat: add initial one-way support for aborting * [EC-598] feat: add ability to throw errors across messenger * [EC-598] feat: transition to using exceptions * [EC-598] feat: add abort controller all the way to service * [EC-598] feat: ability to abort from page script * [EC-598] feat: add automatic default timeouts * [EC-598] chore: move component from generic popup fodler * [EC-598] chore: collect all passkeys stuff under common folder * [EC-598] fix: filter messages from other sources * [EC-598] chore: add small todo comment * [EC-598] feat: add timeout and UV to params * [EC-598] feat: implement full support for timeouts * [EC-598] feat: start creating separate authenticator service * [EC-598] feat: first tested rule in new authentitcator * [EC-598] feat: allow user to confirm duplication * [EC-598] feat: add check for unsupported algorithms * [EC-598] feat: add check for invalid option values * [EC-598] feat: handle unsupported pinAuth * [EC-598] feat: confirm new credentials * [EC-598] feat: rearrange order of execution * [EC-598] chore: rearrange tests * [EC-598] feat: add support for saving discoverable credential * [EC-598] feat: remove ability to duplicate excluded credentials * [EC-598] chore: rearrange tests * [EC-598] feat: add support for non-discoverable credentials * [EC-598] chore: use webauthn authenticator model as base instead of CTAP * [EC-598] feat: don't leak internal errors during creation * [EC-598] feat: tweak key data to contain separate type and algorithm * [EC-598] feat: add counter to fido2key * [EC-598] feat: complete implementation of `makeCredential` * [EC-598] feat: add ignored enterpriseAttestation param * [EC-598] feat: start implementing `getAssertion` * [EC-598] feat: add separate `nonDiscoverableId` to keys * [EC-598] fix: properly convert credentials to guid raw format * [EC-598] chore: add todo tests about deleted items * [EC-598] feat: implement missing credential checks * [EC-598] feat: add user confirmation test to assertion also rewrite to use cipher views in tests * [EC-598] feat: increment counter during assertion * [EC-598] feat: implement assertion * [EC-598] feat: add signatures to attestation * [EC-598] feat: add general error handling for attestation * [EC-598] feat: start working on new `Fido2ClientService` * [EC-598] feat: check user id length * [EC-598] feat: check origin and rp.id effective domains * [EC-598] feat: check for supported key algorithms * [EC-598] feat: hash client data and throw if aborted * [EC-598] feat: extend return from authenticator * [EC-598] feat: fully implement createCredential * [EC-598] feat: implement assertCredential * [EC-598] feat: make everything compile again * [EC-598] feat: remove orgigin * [EC-598] fix: rpId validation logic * [EC-598] fix: some smaller bugs * [EC-598] fix: flag saying authData doesnt contain attestation * [EC-598] fix: wrong flags in tests * [EC-598] fix: data not getting saved properly * [EC-598] fix: invalid signature due to double hashing * [EC-598] chore: clean up unusued function * [EC-598] feat: fully wokring non-discoverable implementation * [EC-598] feat: add initial implementation of UI sessions * [EC-598] feat: fully refactored user interface Now uses sessions instead of single request-response style communcation * [EC-598] feat: make fallback working again * [EC-598] feat: add rudimentary support for excluded credentials * [EC-598] fix: send correct excluded cipher ids * [EC-598] feat: wait for session close before closing window * [EC-598] feat: test unique signatures * [EC-598] chore: clean up old commented code * [EC-598] feat: do not exclude organization credentials * [EC-598] chore: remove unused clas * [EC-598] fix: remove platform attachment check * [EC-598] chore: rename webauthn folder to fido2 * [EC-598] chore: continue rename webauthn to fido2 * [EC-598] feat: interpret rk preferred as required Fixes GoDaddy issues * [EC-598] fix: bug preventing fallback on assertion * [EC-598] feat: inform user when no credentials are found * [EC-598] chore: add some more console logs for debugging * [EC-598] feat: very basic scroll when picking credentials * [EC-598] chore: tweak unique signature test * [EC-598] chore: tweak how unassigned rpId gets calcuated * [EC-598] fix: response prototype chains * [EC-598] feat: allow discoverable credentials to be used for non-discoverable assertions * [EC-598] fix: counter not being saved correctly * [EC-598] fix: bug in result mapping * [EC-598] feat: add support for user verifiction using MP during attestation * [EC-598] feat: add support for user verifiction using MP during assertion * [EC-598] feat: quick fix noop service * [EC-598] chore: refactor observables a little bit * [EC-598] feat: show unsupported user verification error * [EC-598] feat: add logging to fido2 authenticator * [EC-598] feat: add logging to fido2 client * [EC-598] feat: close popout directly from bg script * [EC-598] chore: clean up page-script * [EC-598] feat: add webauthn polyfill * [EC-598] feat: polyfill platform authenticator support * [EC-598] feat: only show fallback options if supported * [EC-598] fix: reponse not correctly polyfilled * [EC-598] chore: add name to polyfill classes * [EC-598] chore: update unsupported UV copy * [EC-598] fix: race condition when opening new popout * Fixed lint issues * [PM-1500] Add feature flag to enable passkeys (#5406) * Added launch darkly feature flag to passkeys implementation * fixed linter * Updated fido2 client service test to accomodate feature flag * Updated fido2client service to include unit test for feature flag * Renamed enable pass keys to fido2 vault credentials, added unit test when feature flag is not enabled * fixed failing Login domain test case * [EC-598] chore: remove unecessary return statement * [EC-598] chore: remove unnecessary eslint disable * [PM-1975] Move FIDO2 files into vault folder (#5496) * Moved fido2 models to vault in libs * Moved fido2 models to vault in libs * Moved fido2 services and abstractions to vault folder in libs * Moved fido2 popup to vault folder on the browser * Updated import path after moving files to the vault folder * Moved authenticator abstraction and service to the vault folder * Updated content and page script path * Added content script, page script and background messaging to vault * fixed lint issue * Updated reference paths * Added missing fallbacksupported property in test files * Added missing fallbacksupported to the newSession method * [PM-2560] Fix Firefox default passkeys handling (#5690) * Return callback response in addListener * Add clarifying comment * Isolate returning the callback to fido2 commands * Update apps/browser/src/platform/browser/browser-api.ts Co-authored-by: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com> * Fix formatting --------- Co-authored-by: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com> * [PM-1976] Display passkeys properly on the browser (#5616) * Removed passkeys from the vault types filter and added fucntion to get the count of Fido2keys and Login types * Updated build filter to take Fido2key type as a Login type * Updated icon font files * Updated vault items and view to handle changes with fido2keys * Updated add edit view for fido2keys * Prevent moving passkeys to an organization where it exists * Prevent moving passkeys to an organization where it exists * Added view for non-discoverable passkeys * Added diaglog to inform user that passkey won't be copied when cloning a non discoverable key * Muted text that shows cipher item is available for 2fa * Changed conditional to check if an organization already has the same passkey item * Muted text to align with figma designs and used rpId for the application input value * Modified checkFido2KeyExistsInOrg function to workk with discoverable and non discoverable keys * Differentiate between non-discoverable and discoverable keys when moving to an organization * Added suggested changes from PR review * Updated font files css changes * Fixed bug preventing launch bitton from working for Login types (#5639) * [PM-1574] Display passkeys on web (#5651) * Allowed discoverable Fido2key type to be displayed alongside Login type * Added view during edit for discoverable and non-discoverable passkeys * Fixed PR comments, added relvant tests to domain changes * Fixed imports and updated the launch function to use the Launchable interface * Added launch on vault filter for fido2key types * Added missing passkey text field in edit view (#5800) * [PM-1977] Display passkeys properly on the desktop (#5763) * Allowed discoverable Fido2key type to be displayed alongside Login type * Added view during edit for discoverable and non-discoverable passkeys * Fixed PR comments, added relvant tests to domain changes * Fixed imports and updated the launch function to use the Launchable interface * Added fido2key to login filter and added view display for fido2key * Added passkeys view for non discoverable passkeys and edit view for passkeys * Fixed PR comments * switched date format to short * [PM-3046] [PM-3047] Defects for discoverable and non-discoverable passkeys on desktop and web (#5847) * Added missing passkey text field in edit view (#5800) * Added dialog to clone no discoverable passkeys on web and desktop.Also, removed clone on the desktop for discoverable passkeys and added passkey view to non- discoverable passkeys on desktop during edit * Prevent cloning dialog on non fido2key ciphers * Made fido2key use website favicon if avaialble instead of the passkey icon * Do not display passkey view on clone edit for dekstop * Do not display passkey view on clone edit for browser * Prevented movement of passkeys ND or D to an organization once one exists and also made it possible for org memebers with user roles to move passkeys to an organization. (#5868) * two step passkey view was outside the conditional (#5872) * fixed merge conflict * [PM-2907] Shopify Passkey Broken on Firefox When Extension is Installed (#6003) * [PM-2907] Shopify Passkey Broken on Firefox When Extension is Installed * [PM-2907] Shopify Passkey Broken on Firefox When Extension is Installed * [PM-2907] Shopify Passkey Broken on Firefox When Extension is Installed * Added passkey fallback imaged and added extension to image name on the icons component * [PM-3155] CLI: Editing a cipher with a non-discoverable passkey causes the passkey to be removed (#6055) * Added fido2keyexport for the CLI and added the fido2key field to the login response for the CLI * Added fido2keyexport for the CLI and added the fido2key field to the login response for the CLI * Removed unneccesary code * Added non discoverable passkey to template * [PM-2270] Renamed Fido2Key.userName to Fido2Key.userDisplayName (#6005) * Renamed fido2key property username to userDisplayName * Renamed username property on fido2key object to userdisplayname * updated username to userDisplayName in fido2 export * Update libs/angular/src/vault/vault-filter/models/vault-filter.model.ts Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com> * [PM-3775] feat: import v0.4.0 (#6183) * [PM-3660] Address PR feedback (#6157) * [PM-3660] chore: simplify object assignment * [PM-3660] fix: remove unused origin field * [PM-3660] feat: add Fido2Key tests * [PM-3660] chore: convert popOut to async func * [PM-3660] chore: refactor if-statements * [PM-3660] chore: simplify closePopOut * [PM-3660] fix: remove confusing comment * [PM-3660] chore: move guid utils away from platform utils * [PM-3660] chore: use null instead of undefined * [PM-3660] chore: use `switch` instead of `if` * [EC-598] fix: popup not closing bug * [PM-1859] Refactor to credentialId (#6034) * PM-1859 Refactor to credentialId * PM-1859 Minor changes * PM-1859 Fix credentialId initialization logic * PM-1859 Added missing logic * PM-1859 Fixed logic to use credentialID instead of cipher.id * [PM-1859] fix: missing renames --------- Co-authored-by: Andreas Coroiu <andreas.coroiu@gmail.com> * [PM-1722] gracefully fail if site prompts user for passkey on load (#6089) * added error logic to look for options.mediation in page-script * moved the options mediation logic into the try catch. changed error to FallbackRequestedError * [PM-1224] Ensure Passkeys Not Requested From Iframes (#6057) * added isNotIFrame method to page-script * added NotAllowedError to assertCredential in fido2 * remove excess comments * refactor fido2-client.service. created new errorhandling method for similar code between create and assert * update types and naming convention for new method in fido2-client.service * Did a reset to previous commit withiout the refactoring to reduce code duplication, Renamed isNotIframeCheck function and fixed other commits * Revert "update types and naming convention for new method in fido2-client.service" This reverts commit1f5499b9bb. * Revert "refactor fido2-client.service. created new errorhandling method for similar code between create and assert" This reverts commit3115c0d2a1. * updated test cases * removed forward slashes --------- Co-authored-by: gbubemismith <gsmithwalter@gmail.com> * [EC-598] Window Messaging Fix; (#6223) Co-authored-by: Cesar Gonzalez <cgonzalez@bitwarden.com> Co-authored-by: SmithThe4th <gsmith@bitwarden.com> * updated test cases and services using the config service * [PM-3807] All passkeys as login ciphers - Minimal implementation to minimize blockers (#6233) * [PM-3807] feat: remove non-discoverable from fido2 user interface class * [PM-3807] feat: merge fido2 component ui * [PM-3807] feat: return `cipherId` from user interface * [PM-3807] feat: merge credential creation logic in authenticator * [PM-3807] feat: merge credential assertion logic in authenticator --------- Co-authored-by: gbubemismith <gsmithwalter@gmail.com> * [PM-3807] Store all passkeys as login cipher type (#6255) * [PM-3807] feat: add `discoverable` property to fido2keys * [PM-3807] feat: assign discoverable property during creation * [PM-3807] feat: save discoverable field to server * [PM-3807] feat: filter credentials by rpId AND discoverable * [PM-3807] chore: remove discoverable tests which are no longer needed * [PM-3807] chore: remove all logic for handling standalone Fido2Key View and components will be cleaned up as part of UI tickets * [PM-3807] fix: add missing discoverable property handling to tests * [PM-3862] chore: move browser fido2 user interface to vault folder (#6265) * [PM-2207], [PM-1245], [PM-3302] Make browser login, lock, and 2fa components handle configurable redirect routes (#5989) * Initial work * Added lock and login redirect and added functionality to abort when in login or locked state * uncommented cipher row * added query params to logi component * Proof of concept for change detection fix * Remove leftover comment * Refactored message listener observable to handle angular change detection * cleanup and removed unused references * Refactored the connect method be seperating to the pop out logic to a seperate method * Added comment to explain code change on the message listener * Removed unused types * Initial work * Added lock and login redirect and added functionality to abort when in login or locked state * uncommented cipher row * added query params to logi component * Proof of concept for change detection fix * Remove leftover comment * Refactored message listener observable to handle angular change detection * cleanup and removed unused references * Refactored the connect method be seperating to the pop out logic to a seperate method * Added comment to explain code change on the message listener * Removed unused types * Added full synce service to the fido2 authenticator to ensure the full sync is completed before getting all decrypted ciphers * Added full synce service to the fido2 authenticator to ensure the full sync is completed before getting all decrypted ciphers * Code cleanup to remove sessionId from login component * Refactored components to make the redirectUrl more generic, fixed code review comments * Commented out ensureUnlockedVault for this PR * Fixed destroy subject inheritance issue on the login componenet * Fixed lock component error * Added function to run inside angular zone * Merged branch with master and fixed conflicts * Changed redirect logic on login and 2fa to use callbacks * fixed pr comments * Updated the messageListener observable version to use same logic from the callback version and added comment on the callback version * Refactored fido2 popup to use auth guard when routing to component, added BrowserRouterService to track previous page and route using that * Updated components to use browserRouterService for routing to previous page * Removed auth status reference from browser-fido2-user-interface service * Removed activated route from lock component * Removed route in base class constructor * removed unused comments and method * refactored router service to not store on the disk * [PM-3783] feat: patch `chrome.runtime.onMessage` event listeners (cherry picked from commit2ca241a0d4) * Fixed PR comments * Fixed PR comments * Revert "[PM-3783] feat: patch `chrome.runtime.onMessage` event listeners" This reverts commited6a713688. --------- Co-authored-by: Thomas Rittson <trittson@bitwarden.com> Co-authored-by: Andreas Coroiu <andreas.coroiu@gmail.com> * [PM-3807] Store passkeys as array (#6288) * [PM-3807] feat: store passkeys as array * [PM-3807] fix: issues in views * [PM-3807] fix: additional view bugs * [PM-3807] fix: check array length * [PM-3807] fix: I secretly like build errors * [PM-3970] Empty list of ciphers when logging in via fido 2 popout (#6321) * fix: sync not being properly called * fix: don't call sync everywhere * [PM-3905] Address PR feedback v2 (#6322) * [PM-3905] chore: move webauthn utils to vault * [PM-3905] chore: make static function private * [PM-3905] chore: add documentation to user interface classes * [PM-3905] chore: clean up unused abort controllers * [PM-3905] chore: add documentation to fido2 client and authenticatio * [PM-3905] chore: extract create credential params mapping to separate function * [PM-3905] chore: extract get assertion params mapping to separate function * [PM-3905] chore: assign requireResidentKey as separate variable * [PM-3905] feat: started rewrite of messenger Basic message sending implemented, now using message channels instead of rxjs * [PM-3905] feat: complete rewrite of messenger * [PM-3905] chore: clarify why we're assigning to window * [PM-3905] feat: clean up tests * [PM-3905] docs: document messenger class * [PM-3905] feat: remove `requestId` which is no longer needed * [PM-3905] feat: simplify message structure * [PM-3905] chore: typo * [PM-3905] chore: clean up old file * [PM-3905] chore: tweak doc comment * [PM-3905] feat: create separate class for managing aborts * [PM-3905] chore: move abort manager to vault * [PM-3980] Add a creationDate field to the Fido2Key object (#6334) * Added creationDate field to be used on the passkeys view instead of the cipher.creationDate * Fixed comments from PR * added to the constructor and sorted out other comments * Exported Fido2KeyExport through index.ts * Fixed iso string issue where the date wasn't converted back to Date (#6364) * [PM-4045] Get error returned when editing an item with a passkey in the CLI (#6379) * Creationdate doesn't get converted to a date * Creationdate doesn't get converted to a date * removed null assignment * [PM-3810] Unify Passkeys view (#6335) * Removed standalone fido2key view, update login view to show created date when a fido2key is present, reverted icon component to previous state without fido2key type, removed filters to handle standalone fido2key as login type * Allow duplication * Removed launchable behaviours from fido2 key view * Reworked desktop views from standalone fido2keys to unified fido2keys in the login * Reworked web views from standalone fido2keys to unified fido2keys in the login * Fixed test case to not create standalone fido2keys * Updated views to use fido2key creation date * removed unused locale * moved logic from template to class * Removed fido2key ciphertype * Removed fido2key ciphertype references * PM-2559 Messaging Rework for Passkey Bug (#6282) * [PM-2559] Messaging Rework - Update browser-api messageListener removing promises to fix Firefox bug Co-authored-by: Cesar Gonzalez <cgonzalez@bitwarden.com> * Resolved merge conflicts from vault item encryption. * moved passkeys ontop totp code to align with the add edit view (#6466) * Bug during reafactoring where the hostname is not used if the rpId is undefined (#6484) * [PM-4054] Rename Fido2Key to Fido2Credential (#6442) * Rename Fido2Key to Fido2Credential * Fix export * Remove unnecessary alis in export * Make test less wordly --------- Co-authored-by: gbubemismith <gsmithwalter@gmail.com> * [PM-3812][PM-3809] Unify Create and Login Passkeys UI (#6403) * PM-1235 Added component to display passkey on auth flow * PM-1235 Implement basic structure and behaviour of UI * PM-1235 Added localised strings * PM-1235 Improved button UI * Implemented view passkey button * Implemented multiple matching passkeys * Refactored fido2 popup to use browser popout windows service * [PM-3807] feat: remove non-discoverable from fido2 user interface class * [PM-3807] feat: merge fido2 component ui * [PM-3807] feat: return `cipherId` from user interface * [PM-3807] feat: merge credential creation logic in authenticator * [PM-3807] feat: merge credential assertion logic in authenticator * updated test cases and services using the config service * [PM-3807] feat: add `discoverable` property to fido2keys * [PM-3807] feat: assign discoverable property during creation * [PM-3807] feat: save discoverable field to server * [PM-3807] feat: filter credentials by rpId AND discoverable * [PM-3807] chore: remove discoverable tests which are no longer needed * [PM-3807] chore: remove all logic for handling standalone Fido2Key View and components will be cleaned up as part of UI tickets * [PM-3807] fix: add missing discoverable property handling to tests * updated locales with new text * Updated popout windows service to use defined type for custom width and height * Update on unifying auth flow ui to align with architecture changes * Moved click event * Throw dom exception error if tab is null * updated fido2key object to array * removed discoverable key in client inerface service for now * Get senderTabId from the query params and send to the view cipher component to allow the pop out close when the close button is clicked on the view cipher component * Refactored view item if passkeys exists and the cipher row views by having an extra ng-conatiner for each case * Allow fido2 pop out close wehn cancle is clicked on add edit component * Removed makshift run in angular zone * created focus directive to target first element in ngFor for displayed ciphers in fido2 * Refactored to use switch statement and added condtional on search and add div * Adjusted footer link and added more features to the login flow * Added host listener to abort when window is closed * remove custom focus directive. instead stuck focus logic into fido2-cipher-row component * Fixed bug where close and cancel on view and add component does not abort the fido2 request * show info dialog when user account does not have master password * Removed PopupUtilsService * show info dialog when user account does not have master password * Added comments * Added comments * made row height consistent * update logo to be dynamic with theme selection * added new translation key * Dis some styling to align cipher items * Changed passkey icon fill color * updated flow of focus and selected items in the passkey popup * Fixed bug when picking a credential * Added text to lock popout screen * Added passkeys test to home view * changed class name * Added uilocation as a query paramter to know if the user is in the popout window * update fido2 component for dynamic subtitleText as well as additional appA11yTitle attrs * moved another method out of html * Added window id return to single action popout and used the window id to close and abort the popout * removed duplicate activatedroute * added a doNotSaveUrl true to 2fa options, so the previousUrl can remain as the fido2 url * Added a div to restrict the use browser link ot the buttom left * reverted view change which is handled by the view pr * Updated locales text and removed unused variable * Fixed issue where new cipher is not created for non discoverable keys * switched from using svg for the logo to CL * removed svg files * default to browser implmentation if user is logged out of the browser exetension * removed passkeys knowledge from login, 2fa * Added fido2 use browser link component and a state service to reduce passkeys knowledge on the lock component * removed function and removed unnecessary comment * reverted to former * [PM-4148] Added descriptive error messages (#6475) * Added descriptive error messages * Added descriptive error messages * replaced fido2 state service with higher order inject functions * removed null check for tab * refactor fido2 cipher row component * added a static abort function to the browser interface service * removed width from content * uncommented code * removed sessionId from query params and redudant styles * Put back removed sessionId * Added fallbackRequested parameter to abortPopout and added comments to the standalone function * minor styling update to fix padding and color on selected ciphers * update padding again to address vertical pushdown of cipher selection --------- Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com> Co-authored-by: Andreas Coroiu <andreas.coroiu@gmail.com> Co-authored-by: jng <jng@bitwarden.com> * padding update for focused cipher row in popup * Updated fido2Credentials to initialize as null instead of empty array (#6548) * Updated fido2Credentials to be null instead of empty string * Updated cipher tests. * Fixed tests. * Updated view and clone logic. * Updated templates to handle null value. * Further null checks. * [PM-4226] Create login item on the fly and add passkey item to it (#6552) * Use the + button to ad an item and then save a passkey on the added item * switch if to tenary * [PM-4284] Passkey popout is not pulling correct URI for website opened (#6549) * Used url from sender window in getting matching logins * Rough draft to combine user verification required and master password required prompts * Revert "Rough draft to combine user verification required and master password required prompts" This reverts commitf72d6f877f. * Remove array initialization that is not necessary. (#6563) * removed unused code from login, 2fa components (#6565) * Moved clearing of passkey from submit to load when cloning. (#6567) * [PM-4280] MP reprompt not respected on passkey creation and retrieval (#6550) * Rough draft to combine user verification required and master password required prompts * Updated the handle user verification logic * allow same behaviour for master password reprompt and user verification * added test cases and merged conditions * [PM-4226] Add Cipher With Passkey Flow Change (#6569) * changed the add login item with passkey to require master password repompt first before creating the cipher item * removed userVerified variable * combined conditionals * added passkey not copied alert when cloning for organizations (#6579) * [PM-4296] Cannot login to Bitwarden with FIDO2 WebAuthn if extension is installed and logged in (#6576) * removed sameOriginWithAncestors check on fido2 assertions * removed sameOriginWithAncestors check on fido2 assertions * [PM-4333] fix: change transport to `internal` (#6594) * Address PR feedback (#6572) * remove listeners for safari * removed unused i18n tokens * changed link to button for accessibilty purposes * Fix potential reference error by restoring the typeof check for chrome * added fromNullable to reduces repetitive logic * Revert "added fromNullable to reduces repetitive logic" This reverts commitce5fc9c278. * Added js docs to fido2credential export * refined jsdocs comments * added documentation to fido2 auth guard * Removed unused i18n tokens, uneccesary whitespaces and comments --------- Co-authored-by: gbubemismith <gsmithwalter@gmail.com> Co-authored-by: SmithThe4th <gsmith@bitwarden.com> Co-authored-by: Robyn MacCallum <robyntmaccallum@gmail.com> Co-authored-by: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com> Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com> Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com> Co-authored-by: Jason Ng <jng@bitwarden.com> Co-authored-by: Todd Martin <106564991+trmartin4@users.noreply.github.com> Co-authored-by: Cesar Gonzalez <cgonzalez@bitwarden.com> Co-authored-by: Thomas Rittson <trittson@bitwarden.com> Co-authored-by: Todd Martin <tmartin@bitwarden.com>
This commit is contained in:
@@ -1253,6 +1253,9 @@
|
||||
"typeIdentity": {
|
||||
"message": "Identity"
|
||||
},
|
||||
"typePasskey": {
|
||||
"message": "Passkey"
|
||||
},
|
||||
"passwordHistory": {
|
||||
"message": "Password history"
|
||||
},
|
||||
@@ -2445,5 +2448,56 @@
|
||||
"turnOffMasterPasswordPromptToEditField": {
|
||||
"message": "Turn off master password re-prompt to edit this field",
|
||||
"description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item."
|
||||
},
|
||||
"passkeyNotCopied": {
|
||||
"message": "Passkey will not be copied"
|
||||
},
|
||||
"passkeyNotCopiedAlert": {
|
||||
"message": "The passkey will not be copied to the cloned item. Do you want to continue cloning this item?"
|
||||
},
|
||||
"passkeyFeatureIsNotImplementedForAccountsWithoutMasterPassword": {
|
||||
"message": "Verification required by the initiating site. This feature is not yet implemented for accounts without master password."
|
||||
},
|
||||
"logInWithPasskey": {
|
||||
"message": "Log in with passkey?"
|
||||
},
|
||||
"passkeyAlreadyExists": {
|
||||
"message": "A passkey already exists for this application."
|
||||
},
|
||||
"noPasskeysFoundForThisApplication": {
|
||||
"message": "No passkeys found for this application."
|
||||
},
|
||||
"noMatchingPasskeyLogin": {
|
||||
"message": "You do not have a matching login for this site."
|
||||
},
|
||||
"confirm": {
|
||||
"message": "Confirm"
|
||||
},
|
||||
"savePasskey": {
|
||||
"message": "Save passkey"
|
||||
},
|
||||
"savePasskeyNewLogin": {
|
||||
"message": "Save passkey as new login"
|
||||
},
|
||||
"choosePasskey": {
|
||||
"message": "Choose a login to save this passkey to"
|
||||
},
|
||||
"passkeyItem": {
|
||||
"message": "Passkey Item"
|
||||
},
|
||||
"overwritePasskey": {
|
||||
"message": "Overwrite passkey?"
|
||||
},
|
||||
"overwritePasskeyAlert": {
|
||||
"message": "This item already contains a passkey. Are you sure you want to overwrite the current passkey?"
|
||||
},
|
||||
"featureNotSupported": {
|
||||
"message": "Feature not yet supported"
|
||||
},
|
||||
"yourPasskeyIsLocked": {
|
||||
"message": "Authentication required to use passkey. Verify your identity to continue."
|
||||
},
|
||||
"useBrowserName": {
|
||||
"message": "Use browser"
|
||||
}
|
||||
}
|
||||
|
||||
34
apps/browser/src/auth/guards/fido2-auth.guard.ts
Normal file
34
apps/browser/src/auth/guards/fido2-auth.guard.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { inject } from "@angular/core";
|
||||
import {
|
||||
ActivatedRouteSnapshot,
|
||||
CanActivateFn,
|
||||
Router,
|
||||
RouterStateSnapshot,
|
||||
} from "@angular/router";
|
||||
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
|
||||
import { BrowserRouterService } from "../../platform/popup/services/browser-router.service";
|
||||
|
||||
/**
|
||||
* This guard verifies the user's authetication status.
|
||||
* If "Locked", it saves the intended route in memory and redirects to the lock screen. Otherwise, the intended route is allowed.
|
||||
*/
|
||||
export const fido2AuthGuard: CanActivateFn = async (
|
||||
route: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot
|
||||
) => {
|
||||
const routerService = inject(BrowserRouterService);
|
||||
const authService = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
const authStatus = await authService.getAuthStatus();
|
||||
|
||||
if (authStatus === AuthenticationStatus.Locked) {
|
||||
routerService.setPreviousUrl(state.url);
|
||||
return router.createUrlTree(["/lock"], { queryParams: route.queryParams });
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -11,81 +11,91 @@
|
||||
</div>
|
||||
</header>
|
||||
<main tabindex="-1">
|
||||
<div class="box">
|
||||
<div class="box-content">
|
||||
<div
|
||||
class="box-content-row box-content-row-flex"
|
||||
appBoxRow
|
||||
*ngIf="pinEnabled || masterPasswordEnabled"
|
||||
>
|
||||
<div class="row-main" *ngIf="pinEnabled">
|
||||
<label for="pin">{{ "pin" | i18n }}</label>
|
||||
<input
|
||||
id="pin"
|
||||
type="{{ showPassword ? 'text' : 'password' }}"
|
||||
name="PIN"
|
||||
class="monospaced"
|
||||
[(ngModel)]="pin"
|
||||
required
|
||||
appInputVerbatim
|
||||
/>
|
||||
</div>
|
||||
<div class="row-main" *ngIf="masterPasswordEnabled && !pinEnabled">
|
||||
<label for="masterPassword">{{ "masterPass" | i18n }}</label>
|
||||
<input
|
||||
id="masterPassword"
|
||||
type="{{ showPassword ? 'text' : 'password' }}"
|
||||
name="MasterPassword"
|
||||
aria-describedby="masterPasswordHelp"
|
||||
class="monospaced"
|
||||
[(ngModel)]="masterPassword"
|
||||
required
|
||||
appInputVerbatim
|
||||
/>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
|
||||
(click)="togglePassword()"
|
||||
[attr.aria-pressed]="showPassword"
|
||||
>
|
||||
<i
|
||||
class="bwi bwi-lg"
|
||||
[ngClass]="{ 'bwi-eye': !showPassword, 'bwi-eye-slash': showPassword }"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</button>
|
||||
<ng-container *ngIf="fido2PopoutSessionData$ | async as fido2Data">
|
||||
<div class="box">
|
||||
<div class="box-content">
|
||||
<div
|
||||
class="box-content-row box-content-row-flex"
|
||||
appBoxRow
|
||||
*ngIf="pinEnabled || masterPasswordEnabled"
|
||||
>
|
||||
<div class="row-main" *ngIf="pinEnabled">
|
||||
<label for="pin">{{ "pin" | i18n }}</label>
|
||||
<input
|
||||
id="pin"
|
||||
type="{{ showPassword ? 'text' : 'password' }}"
|
||||
name="PIN"
|
||||
class="monospaced"
|
||||
[(ngModel)]="pin"
|
||||
required
|
||||
appInputVerbatim
|
||||
/>
|
||||
</div>
|
||||
<div class="row-main" *ngIf="masterPasswordEnabled && !pinEnabled">
|
||||
<label for="masterPassword">{{ "masterPass" | i18n }}</label>
|
||||
<input
|
||||
id="masterPassword"
|
||||
type="{{ showPassword ? 'text' : 'password' }}"
|
||||
name="MasterPassword"
|
||||
aria-describedby="masterPasswordHelp"
|
||||
class="monospaced"
|
||||
[(ngModel)]="masterPassword"
|
||||
required
|
||||
appInputVerbatim
|
||||
/>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
|
||||
(click)="togglePassword()"
|
||||
[attr.aria-pressed]="showPassword"
|
||||
>
|
||||
<i
|
||||
class="bwi bwi-lg"
|
||||
[ngClass]="{ 'bwi-eye': !showPassword, 'bwi-eye-slash': showPassword }"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="masterPasswordHelp" class="box-footer">
|
||||
<p>
|
||||
{{
|
||||
fido2Data.isFido2Session
|
||||
? ("yourPasskeyIsLocked" | i18n)
|
||||
: ("yourVaultIsLocked" | i18n)
|
||||
}}
|
||||
</p>
|
||||
{{ "loggedInAsOn" | i18n : email : webVaultHostname }}
|
||||
</div>
|
||||
</div>
|
||||
<div id="masterPasswordHelp" class="box-footer">
|
||||
<p>{{ "yourVaultIsLocked" | i18n }}</p>
|
||||
{{ "loggedInAsOn" | i18n : email : webVaultHostname }}
|
||||
<div class="box" *ngIf="biometricLock">
|
||||
<div class="box-footer no-pad">
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary block"
|
||||
(click)="unlockBiometric()"
|
||||
appStopClick
|
||||
[disabled]="pendingBiometric"
|
||||
>
|
||||
{{ "unlockWithBiometrics" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" *ngIf="biometricLock">
|
||||
<div class="box-footer no-pad">
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary block"
|
||||
(click)="unlockBiometric()"
|
||||
appStopClick
|
||||
[disabled]="pendingBiometric"
|
||||
>
|
||||
{{ "unlockWithBiometrics" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-center">
|
||||
<button type="button" appStopClick (click)="logOut()">{{ "logOut" | i18n }}</button>
|
||||
</p>
|
||||
<app-private-mode-warning></app-private-mode-warning>
|
||||
<app-callout *ngIf="biometricError" type="error">{{ biometricError }}</app-callout>
|
||||
<p class="text-center text-muted" *ngIf="pendingBiometric">
|
||||
<i class="bwi bwi-spinner bwi-spin" aria-hidden="true"></i> {{ "awaitDesktop" | i18n }}
|
||||
</p>
|
||||
<p class="text-center" *ngIf="!fido2Data.isFido2Session">
|
||||
<button type="button" appStopClick (click)="logOut()">{{ "logOut" | i18n }}</button>
|
||||
</p>
|
||||
<app-private-mode-warning></app-private-mode-warning>
|
||||
<app-callout *ngIf="biometricError" type="error">{{ biometricError }}</app-callout>
|
||||
<p class="text-center text-muted" *ngIf="pendingBiometric">
|
||||
<i class="bwi bwi-spinner bwi-spin" aria-hidden="true"></i> {{ "awaitDesktop" | i18n }}
|
||||
</p>
|
||||
|
||||
<app-fido2-use-browser-link></app-fido2-use-browser-link>
|
||||
</ng-container>
|
||||
</main>
|
||||
</form>
|
||||
|
||||
@@ -22,6 +22,8 @@ import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/pass
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
import { BiometricErrors, BiometricErrorTypes } from "../../models/biometricErrors";
|
||||
import { BrowserRouterService } from "../../platform/popup/services/browser-router.service";
|
||||
import { fido2PopoutSessionData$ } from "../../vault/fido2/browser-fido2-user-interface.service";
|
||||
|
||||
@Component({
|
||||
selector: "app-lock",
|
||||
@@ -32,6 +34,7 @@ export class LockComponent extends BaseLockComponent {
|
||||
|
||||
biometricError: string;
|
||||
pendingBiometric = false;
|
||||
fido2PopoutSessionData$ = fido2PopoutSessionData$();
|
||||
|
||||
constructor(
|
||||
router: Router,
|
||||
@@ -52,7 +55,8 @@ export class LockComponent extends BaseLockComponent {
|
||||
private authService: AuthService,
|
||||
dialogService: DialogService,
|
||||
deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
|
||||
userVerificationService: UserVerificationService
|
||||
userVerificationService: UserVerificationService,
|
||||
private routerService: BrowserRouterService
|
||||
) {
|
||||
super(
|
||||
router,
|
||||
@@ -76,6 +80,15 @@ export class LockComponent extends BaseLockComponent {
|
||||
);
|
||||
this.successRoute = "/tabs/current";
|
||||
this.isInitialLockScreen = (window as any).previousPopupUrl == null;
|
||||
|
||||
super.onSuccessfulSubmit = async () => {
|
||||
const previousUrl = this.routerService.getPreviousUrl();
|
||||
if (previousUrl) {
|
||||
this.router.navigateByUrl(previousUrl);
|
||||
} else {
|
||||
this.router.navigate([this.successRoute]);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
|
||||
@@ -20,17 +20,16 @@ export default class ContextMenusBackground {
|
||||
|
||||
BrowserApi.messageListener(
|
||||
"contextmenus.background",
|
||||
async (
|
||||
(
|
||||
msg: { command: string; data: LockedVaultPendingNotificationsItem },
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: any
|
||||
sender: chrome.runtime.MessageSender
|
||||
) => {
|
||||
if (msg.command === "unlockCompleted" && msg.data.target === "contextmenus.background") {
|
||||
await this.contextMenuClickedHandler.cipherAction(
|
||||
msg.data.commandToRetry.msg.data,
|
||||
msg.data.commandToRetry.sender.tab
|
||||
);
|
||||
await BrowserApi.tabSendMessageData(sender.tab, "closeNotificationBar");
|
||||
this.contextMenuClickedHandler
|
||||
.cipherAction(msg.data.commandToRetry.msg.data, msg.data.commandToRetry.sender.tab)
|
||||
.then(() => {
|
||||
BrowserApi.tabSendMessageData(sender.tab, "closeNotificationBar");
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -47,8 +47,8 @@ export default class NotificationBackground {
|
||||
|
||||
BrowserApi.messageListener(
|
||||
"notification.background",
|
||||
async (msg: any, sender: chrome.runtime.MessageSender) => {
|
||||
await this.processMessage(msg, sender);
|
||||
(msg: any, sender: chrome.runtime.MessageSender) => {
|
||||
this.processMessage(msg, sender);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -25,17 +25,11 @@ export default class CommandsBackground {
|
||||
}
|
||||
|
||||
async init() {
|
||||
BrowserApi.messageListener(
|
||||
"commands.background",
|
||||
async (msg: any, sender: chrome.runtime.MessageSender, sendResponse: any) => {
|
||||
if (msg.command === "unlockCompleted" && msg.data.target === "commands.background") {
|
||||
await this.processCommand(
|
||||
msg.data.commandToRetry.msg.command,
|
||||
msg.data.commandToRetry.sender
|
||||
);
|
||||
}
|
||||
BrowserApi.messageListener("commands.background", (msg: any) => {
|
||||
if (msg.command === "unlockCompleted" && msg.data.target === "commands.background") {
|
||||
this.processCommand(msg.data.commandToRetry.msg.command, msg.data.commandToRetry.sender);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (chrome && chrome.commands) {
|
||||
chrome.commands.onCommand.addListener(async (command: string) => {
|
||||
|
||||
@@ -87,6 +87,9 @@ import { SendApiService as SendApiServiceAbstraction } from "@bitwarden/common/t
|
||||
import { InternalSendService as InternalSendServiceAbstraction } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CollectionService as CollectionServiceAbstraction } from "@bitwarden/common/vault/abstractions/collection.service";
|
||||
import { Fido2AuthenticatorService as Fido2AuthenticatorServiceAbstraction } from "@bitwarden/common/vault/abstractions/fido2/fido2-authenticator.service.abstraction";
|
||||
import { Fido2ClientService as Fido2ClientServiceAbstraction } from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
|
||||
import { Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction } from "@bitwarden/common/vault/abstractions/fido2/fido2-user-interface.service.abstraction";
|
||||
import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service";
|
||||
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
|
||||
import { InternalFolderService as InternalFolderServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
@@ -95,6 +98,8 @@ import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/vault/a
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
|
||||
import { CollectionService } from "@bitwarden/common/vault/services/collection.service";
|
||||
import { Fido2AuthenticatorService } from "@bitwarden/common/vault/services/fido2/fido2-authenticator.service";
|
||||
import { Fido2ClientService } from "@bitwarden/common/vault/services/fido2/fido2-client.service";
|
||||
import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service";
|
||||
import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service";
|
||||
import { SyncNotifierService } from "@bitwarden/common/vault/services/sync/sync-notifier.service";
|
||||
@@ -138,9 +143,11 @@ import BrowserPlatformUtilsService from "../platform/services/browser-platform-u
|
||||
import { BrowserStateService } from "../platform/services/browser-state.service";
|
||||
import { KeyGenerationService } from "../platform/services/key-generation.service";
|
||||
import { LocalBackedSessionStorageService } from "../platform/services/local-backed-session-storage.service";
|
||||
import { PopupUtilsService } from "../popup/services/popup-utils.service";
|
||||
import { BrowserSendService } from "../services/browser-send.service";
|
||||
import { BrowserSettingsService } from "../services/browser-settings.service";
|
||||
import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service";
|
||||
import { BrowserFido2UserInterfaceService } from "../vault/fido2/browser-fido2-user-interface.service";
|
||||
import { BrowserFolderService } from "../vault/services/browser-folder.service";
|
||||
import { VaultFilterService } from "../vault/services/vault-filter.service";
|
||||
|
||||
@@ -204,6 +211,9 @@ export default class MainBackground {
|
||||
sendApiService: SendApiServiceAbstraction;
|
||||
userVerificationApiService: UserVerificationApiServiceAbstraction;
|
||||
syncNotifierService: SyncNotifierServiceAbstraction;
|
||||
fido2UserInterfaceService: Fido2UserInterfaceServiceAbstraction;
|
||||
fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction;
|
||||
fido2ClientService: Fido2ClientServiceAbstraction;
|
||||
avatarUpdateService: AvatarUpdateServiceAbstraction;
|
||||
mainContextMenuHandler: MainContextMenuHandler;
|
||||
cipherContextMenuHandler: CipherContextMenuHandler;
|
||||
@@ -213,6 +223,7 @@ export default class MainBackground {
|
||||
devicesService: DevicesServiceAbstraction;
|
||||
deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction;
|
||||
authRequestCryptoService: AuthRequestCryptoServiceAbstraction;
|
||||
popupUtilsService: PopupUtilsService;
|
||||
browserPopoutWindowService: BrowserPopoutWindowService;
|
||||
|
||||
// Passed to the popup for Safari to workaround issues with theming, downloading, etc.
|
||||
@@ -370,7 +381,7 @@ export default class MainBackground {
|
||||
// AuthService should send the messages to the background not popup.
|
||||
send = (subscriber: string, arg: any = {}) => {
|
||||
const message = Object.assign({}, { command: subscriber }, arg);
|
||||
that.runtimeBackground.processMessage(message, that as any, null);
|
||||
that.runtimeBackground.processMessage(message, that as any);
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -569,6 +580,22 @@ export default class MainBackground {
|
||||
|
||||
this.browserPopoutWindowService = new BrowserPopoutWindowService();
|
||||
|
||||
this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(
|
||||
this.browserPopoutWindowService
|
||||
);
|
||||
this.fido2AuthenticatorService = new Fido2AuthenticatorService(
|
||||
this.cipherService,
|
||||
this.fido2UserInterfaceService,
|
||||
this.syncService,
|
||||
this.logService
|
||||
);
|
||||
this.fido2ClientService = new Fido2ClientService(
|
||||
this.fido2AuthenticatorService,
|
||||
this.configService,
|
||||
this.authService,
|
||||
this.logService
|
||||
);
|
||||
|
||||
const systemUtilsServiceReloadCallback = () => {
|
||||
const forceWindowReload =
|
||||
this.platformUtilsService.isSafari() ||
|
||||
|
||||
@@ -383,7 +383,7 @@ export class NativeMessagingBackground {
|
||||
return;
|
||||
}
|
||||
|
||||
this.runtimeBackground.processMessage({ command: "unlocked" }, null, null);
|
||||
this.runtimeBackground.processMessage({ command: "unlocked" }, null);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { BrowserPopoutWindowService } from "../platform/popup/abstractions/brows
|
||||
import { BrowserStateService } from "../platform/services/abstractions/browser-state.service";
|
||||
import { BrowserEnvironmentService } from "../platform/services/browser-environment.service";
|
||||
import BrowserPlatformUtilsService from "../platform/services/browser-platform-utils.service";
|
||||
import { AbortManager } from "../vault/background/abort-manager";
|
||||
|
||||
import MainBackground from "./main.background";
|
||||
import LockedVaultPendingNotificationsItem from "./models/lockedVaultPendingNotificationsItem";
|
||||
@@ -23,6 +24,7 @@ export default class RuntimeBackground {
|
||||
private pageDetailsToAutoFill: any[] = [];
|
||||
private onInstalledReason: string = null;
|
||||
private lockedVaultPendingNotifications: LockedVaultPendingNotificationsItem[] = [];
|
||||
private abortManager = new AbortManager();
|
||||
|
||||
constructor(
|
||||
private main: MainBackground,
|
||||
@@ -50,12 +52,27 @@ export default class RuntimeBackground {
|
||||
}
|
||||
|
||||
await this.checkOnInstalled();
|
||||
const backgroundMessageListener = async (
|
||||
const backgroundMessageListener = (
|
||||
msg: any,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: any
|
||||
) => {
|
||||
await this.processMessage(msg, sender, sendResponse);
|
||||
const messagesWithResponse = [
|
||||
"checkFido2FeatureEnabled",
|
||||
"fido2RegisterCredentialRequest",
|
||||
"fido2GetCredentialRequest",
|
||||
];
|
||||
|
||||
if (messagesWithResponse.includes(msg.command)) {
|
||||
this.processMessage(msg, sender).then(
|
||||
(value) => sendResponse({ result: value }),
|
||||
(error) => sendResponse({ error: { ...error, message: error.message } })
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
this.processMessage(msg, sender);
|
||||
return false;
|
||||
};
|
||||
|
||||
BrowserApi.messageListener("runtime.background", backgroundMessageListener);
|
||||
@@ -64,7 +81,7 @@ export default class RuntimeBackground {
|
||||
}
|
||||
}
|
||||
|
||||
async processMessage(msg: any, sender: chrome.runtime.MessageSender, sendResponse: any) {
|
||||
async processMessage(msg: any, sender: chrome.runtime.MessageSender) {
|
||||
const cipherId = msg.data?.cipherId;
|
||||
|
||||
switch (msg.command) {
|
||||
@@ -282,8 +299,19 @@ export default class RuntimeBackground {
|
||||
case "getClickedElementResponse":
|
||||
this.platformUtilsService.copyToClipboard(msg.identifier, { window: window });
|
||||
break;
|
||||
default:
|
||||
case "fido2AbortRequest":
|
||||
this.abortManager.abort(msg.abortedRequestId);
|
||||
break;
|
||||
case "checkFido2FeatureEnabled":
|
||||
return await this.main.fido2ClientService.isFido2FeatureEnabled();
|
||||
case "fido2RegisterCredentialRequest":
|
||||
return await this.abortManager.runWithAbortController(msg.requestId, (abortController) =>
|
||||
this.main.fido2ClientService.createCredential(msg.data, sender.tab, abortController)
|
||||
);
|
||||
case "fido2GetCredentialRequest":
|
||||
return await this.abortManager.runWithAbortController(msg.requestId, (abortController) =>
|
||||
this.main.fido2ClientService.assertCredential(msg.data, sender.tab, abortController)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"content_scripts": [
|
||||
{
|
||||
"all_frames": true,
|
||||
"js": ["content/trigger-autofill-script-injection.js"],
|
||||
"js": ["content/trigger-autofill-script-injection.js", "content/fido2/content-script.js"],
|
||||
"matches": ["http://*/*", "https://*/*", "file:///*"],
|
||||
"run_at": "document_start"
|
||||
},
|
||||
@@ -93,6 +93,7 @@
|
||||
}
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
"content/fido2/page-script.js",
|
||||
"notification/bar.html",
|
||||
"images/icon38.png",
|
||||
"images/icon38_locked.png"
|
||||
|
||||
@@ -106,7 +106,12 @@
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["notification/bar.html", "images/icon38.png", "images/icon38_locked.png"],
|
||||
"resources": [
|
||||
"content/webauthn/page-script.js",
|
||||
"notification/bar.html",
|
||||
"images/icon38.png",
|
||||
"images/icon38_locked.png"
|
||||
],
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
|
||||
import { TabMessage } from "../../types/tab-messages";
|
||||
@@ -35,6 +37,10 @@ export class BrowserApi {
|
||||
);
|
||||
}
|
||||
|
||||
static async removeWindow(windowId: number) {
|
||||
await chrome.windows.remove(windowId);
|
||||
}
|
||||
|
||||
static async getTabFromCurrentWindowId(): Promise<chrome.tabs.Tab> | null {
|
||||
return await BrowserApi.tabsQueryFirst({
|
||||
active: true,
|
||||
@@ -199,6 +205,14 @@ export class BrowserApi {
|
||||
BrowserApi.removeTab(tabToClose.id);
|
||||
}
|
||||
|
||||
static createNewWindow(
|
||||
url: string,
|
||||
focused = true,
|
||||
type: chrome.windows.createTypeEnum = "normal"
|
||||
) {
|
||||
chrome.windows.create({ url, focused, type });
|
||||
}
|
||||
|
||||
// Keep track of all the events registered in a Safari popup so we can remove
|
||||
// them when the popup gets unloaded, otherwise we cause a memory leak
|
||||
private static registeredMessageListeners: any[] = [];
|
||||
@@ -206,7 +220,11 @@ export class BrowserApi {
|
||||
|
||||
static messageListener(
|
||||
name: string,
|
||||
callback: (message: any, sender: chrome.runtime.MessageSender, response: any) => void
|
||||
callback: (
|
||||
message: any,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: any
|
||||
) => boolean | void
|
||||
) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chrome.runtime.onMessage.addListener(callback);
|
||||
@@ -244,6 +262,27 @@ export class BrowserApi {
|
||||
};
|
||||
}
|
||||
|
||||
static messageListener$() {
|
||||
return new Observable<unknown>((subscriber) => {
|
||||
const handler = (message: unknown) => {
|
||||
subscriber.next(message);
|
||||
};
|
||||
|
||||
BrowserApi.messageListener("message", handler);
|
||||
|
||||
return () => {
|
||||
chrome.runtime.onMessage.removeListener(handler);
|
||||
|
||||
if (BrowserApi.isSafariApi) {
|
||||
const index = BrowserApi.registeredMessageListeners.indexOf(handler);
|
||||
if (index !== -1) {
|
||||
BrowserApi.registeredMessageListeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
static sendMessage(subscriber: string, arg: any = {}) {
|
||||
const message = Object.assign({}, { command: subscriber }, arg);
|
||||
return chrome.runtime.sendMessage(message);
|
||||
|
||||
@@ -73,10 +73,9 @@ export class SessionSyncer {
|
||||
|
||||
private listenForUpdates() {
|
||||
// This is an unawaited promise, but it will be executed asynchronously in the background.
|
||||
BrowserApi.messageListener(
|
||||
this.updateMessageCommand,
|
||||
async (message) => await this.updateFromMessage(message)
|
||||
);
|
||||
BrowserApi.messageListener(this.updateMessageCommand, (message) => {
|
||||
this.updateFromMessage(message);
|
||||
});
|
||||
}
|
||||
|
||||
async updateFromMessage(message: any) {
|
||||
|
||||
@@ -28,6 +28,15 @@ interface BrowserPopoutWindowService {
|
||||
}
|
||||
): Promise<void>;
|
||||
closePasswordRepromptPrompt(): Promise<void>;
|
||||
openFido2Popout(
|
||||
senderWindow: chrome.tabs.Tab,
|
||||
promptData: {
|
||||
sessionId: string;
|
||||
senderTabId: number;
|
||||
fallbackSupported: boolean;
|
||||
}
|
||||
): Promise<number>;
|
||||
closeFido2Popout(): Promise<void>;
|
||||
}
|
||||
|
||||
export { BrowserPopoutWindowService };
|
||||
|
||||
@@ -95,29 +95,71 @@ class BrowserPopoutWindowService implements BrowserPopupWindowServiceInterface {
|
||||
await this.closeSingleActionPopout("passwordReprompt");
|
||||
}
|
||||
|
||||
async openFido2Popout(
|
||||
senderWindow: chrome.tabs.Tab,
|
||||
{
|
||||
sessionId,
|
||||
senderTabId,
|
||||
fallbackSupported,
|
||||
}: {
|
||||
sessionId: string;
|
||||
senderTabId: number;
|
||||
fallbackSupported: boolean;
|
||||
}
|
||||
): Promise<number> {
|
||||
await this.closeFido2Popout();
|
||||
|
||||
const promptWindowPath =
|
||||
"popup/index.html#/fido2" +
|
||||
"?uilocation=popout" +
|
||||
`&sessionId=${sessionId}` +
|
||||
`&fallbackSupported=${fallbackSupported}` +
|
||||
`&senderTabId=${senderTabId}` +
|
||||
`&senderUrl=${encodeURIComponent(senderWindow.url)}`;
|
||||
|
||||
return await this.openSingleActionPopout(
|
||||
senderWindow.windowId,
|
||||
promptWindowPath,
|
||||
"fido2Popout",
|
||||
{
|
||||
width: 200,
|
||||
height: 500,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async closeFido2Popout(): Promise<void> {
|
||||
await this.closeSingleActionPopout("fido2Popout");
|
||||
}
|
||||
|
||||
private async openSingleActionPopout(
|
||||
senderWindowId: number,
|
||||
popupWindowURL: string,
|
||||
singleActionPopoutKey: string
|
||||
) {
|
||||
singleActionPopoutKey: string,
|
||||
options: chrome.windows.CreateData = {}
|
||||
): Promise<number> {
|
||||
const senderWindow = senderWindowId && (await BrowserApi.getWindow(senderWindowId));
|
||||
const url = chrome.extension.getURL(popupWindowURL);
|
||||
const offsetRight = 15;
|
||||
const offsetTop = 90;
|
||||
const popupWidth = this.defaultPopoutWindowOptions.width;
|
||||
/// Use overrides in `options` if provided, otherwise use default
|
||||
const popupWidth = options?.width || this.defaultPopoutWindowOptions.width;
|
||||
const windowOptions = senderWindow
|
||||
? {
|
||||
...this.defaultPopoutWindowOptions,
|
||||
url,
|
||||
left: senderWindow.left + senderWindow.width - popupWidth - offsetRight,
|
||||
top: senderWindow.top + offsetTop,
|
||||
...options,
|
||||
url,
|
||||
}
|
||||
: { ...this.defaultPopoutWindowOptions, url };
|
||||
: { ...this.defaultPopoutWindowOptions, url, ...options };
|
||||
|
||||
const popupWindow = await BrowserApi.createWindow(windowOptions);
|
||||
|
||||
await this.closeSingleActionPopout(singleActionPopoutKey);
|
||||
this.singleActionPopoutTabIds[singleActionPopoutKey] = popupWindow?.tabs[0].id;
|
||||
|
||||
return popupWindow.id;
|
||||
}
|
||||
|
||||
private async closeSingleActionPopout(popoutKey: string) {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { ActivatedRouteSnapshot, NavigationEnd, Router } from "@angular/router";
|
||||
import { filter } from "rxjs";
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class BrowserRouterService {
|
||||
private previousUrl?: string = undefined;
|
||||
|
||||
constructor(router: Router) {
|
||||
router.events
|
||||
.pipe(filter((e) => e instanceof NavigationEnd))
|
||||
.subscribe((event: NavigationEnd) => {
|
||||
const state: ActivatedRouteSnapshot = router.routerState.snapshot.root;
|
||||
|
||||
let child = state.firstChild;
|
||||
while (child.firstChild) {
|
||||
child = child.firstChild;
|
||||
}
|
||||
|
||||
const updateUrl = !child?.data?.doNotSaveUrl ?? true;
|
||||
|
||||
if (updateUrl) {
|
||||
this.setPreviousUrl(event.url);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getPreviousUrl() {
|
||||
return this.previousUrl;
|
||||
}
|
||||
|
||||
setPreviousUrl(url: string) {
|
||||
this.previousUrl = url;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { canAccessFeature } from "@bitwarden/angular/guard/feature-flag.guard";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
|
||||
import { fido2AuthGuard } from "../auth/guards/fido2-auth.guard";
|
||||
import { EnvironmentComponent } from "../auth/popup/environment.component";
|
||||
import { HintComponent } from "../auth/popup/hint.component";
|
||||
import { HomeComponent } from "../auth/popup/home.component";
|
||||
@@ -31,6 +32,7 @@ import { SendAddEditComponent } from "../tools/popup/send/send-add-edit.componen
|
||||
import { SendGroupingsComponent } from "../tools/popup/send/send-groupings.component";
|
||||
import { SendTypeComponent } from "../tools/popup/send/send-type.component";
|
||||
import { ExportComponent } from "../tools/popup/settings/export.component";
|
||||
import { Fido2Component } from "../vault/popup/components/fido2/fido2.component";
|
||||
import { AddEditComponent } from "../vault/popup/components/vault/add-edit.component";
|
||||
import { AttachmentsComponent } from "../vault/popup/components/vault/attachments.component";
|
||||
import { CollectionsComponent } from "../vault/popup/components/vault/collections.component";
|
||||
@@ -73,6 +75,12 @@ const routes: Routes = [
|
||||
canActivate: [UnauthGuard],
|
||||
data: { state: "home" },
|
||||
},
|
||||
{
|
||||
path: "fido2",
|
||||
component: Fido2Component,
|
||||
canActivate: [fido2AuthGuard],
|
||||
data: { state: "fido2" },
|
||||
},
|
||||
{
|
||||
path: "login",
|
||||
component: LoginComponent,
|
||||
@@ -95,7 +103,7 @@ const routes: Routes = [
|
||||
path: "lock",
|
||||
component: LockComponent,
|
||||
canActivate: [lockGuard()],
|
||||
data: { state: "lock" },
|
||||
data: { state: "lock", doNotSaveUrl: true },
|
||||
},
|
||||
{
|
||||
path: "2fa",
|
||||
|
||||
@@ -80,11 +80,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
window.onkeypress = () => this.recordActivity();
|
||||
});
|
||||
|
||||
(window as any).bitwardenPopupMainMessageListener = async (
|
||||
msg: any,
|
||||
sender: any,
|
||||
sendResponse: any
|
||||
) => {
|
||||
const bitwardenPopupMainMessageListener = (msg: any, sender: any) => {
|
||||
if (msg.command === "doneLoggingOut") {
|
||||
this.authService.logOut(async () => {
|
||||
if (msg.expired) {
|
||||
@@ -102,15 +98,13 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
this.changeDetectorRef.detectChanges();
|
||||
} else if (msg.command === "authBlocked") {
|
||||
this.router.navigate(["home"]);
|
||||
} else if (msg.command === "locked") {
|
||||
if (msg.userId == null || msg.userId === (await this.stateService.getUserId())) {
|
||||
this.router.navigate(["lock"]);
|
||||
}
|
||||
} else if (msg.command === "locked" && msg.userId == null) {
|
||||
this.router.navigate(["lock"]);
|
||||
} else if (msg.command === "showDialog") {
|
||||
await this.ngZone.run(() => this.showDialog(msg));
|
||||
this.showDialog(msg);
|
||||
} else if (msg.command === "showNativeMessagingFinterprintDialog") {
|
||||
// TODO: Should be refactored to live in another service.
|
||||
await this.ngZone.run(() => this.showNativeMessagingFingerprintDialog(msg));
|
||||
this.showNativeMessagingFingerprintDialog(msg);
|
||||
} else if (msg.command === "showToast") {
|
||||
this.showToast(msg);
|
||||
} else if (msg.command === "reloadProcess") {
|
||||
@@ -133,7 +127,8 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
};
|
||||
|
||||
BrowserApi.messageListener("app.component", (window as any).bitwardenPopupMainMessageListener);
|
||||
(window as any).bitwardenPopupMainMessageListener = bitwardenPopupMainMessageListener;
|
||||
BrowserApi.messageListener("app.component", bitwardenPopupMainMessageListener);
|
||||
|
||||
// eslint-disable-next-line rxjs/no-async-subscribe
|
||||
this.router.events.pipe(takeUntil(this.destroy$)).subscribe(async (event) => {
|
||||
|
||||
@@ -39,6 +39,9 @@ import { SendTypeComponent } from "../tools/popup/send/send-type.component";
|
||||
import { ExportComponent } from "../tools/popup/settings/export.component";
|
||||
import { ActionButtonsComponent } from "../vault/popup/components/action-buttons.component";
|
||||
import { CipherRowComponent } from "../vault/popup/components/cipher-row.component";
|
||||
import { Fido2CipherRowComponent } from "../vault/popup/components/fido2/fido2-cipher-row.component";
|
||||
import { Fido2UseBrowserLinkComponent } from "../vault/popup/components/fido2/fido2-use-browser-link.component";
|
||||
import { Fido2Component } from "../vault/popup/components/fido2/fido2.component";
|
||||
import { AddEditCustomFieldsComponent } from "../vault/popup/components/vault/add-edit-custom-fields.component";
|
||||
import { AddEditComponent } from "../vault/popup/components/vault/add-edit.component";
|
||||
import { AttachmentsComponent } from "../vault/popup/components/vault/attachments.component";
|
||||
@@ -111,6 +114,8 @@ import "../platform/popup/locales";
|
||||
EnvironmentComponent,
|
||||
ExcludedDomainsComponent,
|
||||
ExportComponent,
|
||||
Fido2CipherRowComponent,
|
||||
Fido2UseBrowserLinkComponent,
|
||||
FolderAddEditComponent,
|
||||
FoldersComponent,
|
||||
VaultFilterComponent,
|
||||
@@ -148,6 +153,7 @@ import "../platform/popup/locales";
|
||||
ViewCustomFieldsComponent,
|
||||
RemovePasswordComponent,
|
||||
VaultSelectComponent,
|
||||
Fido2Component,
|
||||
HelpAndFeedbackComponent,
|
||||
AutofillComponent,
|
||||
EnvironmentSelectorComponent,
|
||||
|
||||
BIN
apps/browser/src/popup/images/bwi-passkey.png
Normal file
BIN
apps/browser/src/popup/images/bwi-passkey.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -153,6 +153,14 @@ body.body-full {
|
||||
margin: 15px 0 15px 0;
|
||||
}
|
||||
|
||||
.useBrowserlink {
|
||||
padding: 0 10px 5px 10px;
|
||||
position: fixed;
|
||||
bottom: 10px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
app-options {
|
||||
.box {
|
||||
margin: 10px 0;
|
||||
@@ -175,3 +183,170 @@ app-vault-attachments {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app-fido2 {
|
||||
.auth-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 24px 12px 24px;
|
||||
|
||||
.auth-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.left {
|
||||
padding-right: 10px;
|
||||
|
||||
.logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
i.bwi {
|
||||
font-size: 35px;
|
||||
margin-right: 3px;
|
||||
@include themify($themes) {
|
||||
color: themed("primaryColor");
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 45px;
|
||||
font-weight: 300;
|
||||
margin-top: -3px;
|
||||
@include themify($themes) {
|
||||
color: themed("primaryColor");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
padding: 7px 10px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
.bwi {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
left: 20px;
|
||||
|
||||
@include themify($themes) {
|
||||
color: themed("labelColor");
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border: none;
|
||||
padding: 5px 10px 5px 30px;
|
||||
border-radius: $border-radius;
|
||||
|
||||
&:focus {
|
||||
border-radius: $border-radius;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&[type="search"]::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-repeat: no-repeat;
|
||||
mask-image: none;
|
||||
-webkit-mask-image: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.auth-flow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
|
||||
.subtitle {
|
||||
font-family: Open Sans;
|
||||
font-size: 24px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.box.list {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.box-content {
|
||||
max-height: 140px;
|
||||
}
|
||||
|
||||
@media screen and (min-height: 501px) and (max-height: 600px) {
|
||||
.box-content {
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-height: 601px) {
|
||||
.box-content {
|
||||
max-height: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
.box-content-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
button {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.row-main {
|
||||
border-radius: 6px;
|
||||
padding: 5px 0px 5px 12px;
|
||||
|
||||
&:focus {
|
||||
@include themify($themes) {
|
||||
padding: 3px 0px 3px 10px;
|
||||
border: 2px solid themed("headerInputBackgroundFocusColor");
|
||||
}
|
||||
}
|
||||
|
||||
&.row-selected {
|
||||
@include themify($themes) {
|
||||
outline: none;
|
||||
padding-left: 7px;
|
||||
border-left: 5px solid themed("primaryColor");
|
||||
background-color: themed("headerBackgroundHoverColor");
|
||||
color: themed("headerColor");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.row-main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
.detail {
|
||||
min-height: 15px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,16 @@ import { fromEvent, Subscription } from "rxjs";
|
||||
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
|
||||
export type Popout =
|
||||
| {
|
||||
type: "window";
|
||||
window: chrome.windows.Window;
|
||||
}
|
||||
| {
|
||||
type: "tab";
|
||||
tab: chrome.tabs.Tab;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PopupUtilsService {
|
||||
private unloadSubscription: Subscription;
|
||||
@@ -45,12 +55,16 @@ export class PopupUtilsService {
|
||||
}
|
||||
}
|
||||
|
||||
popOut(win: Window, href: string = null): void {
|
||||
async popOut(
|
||||
win: Window,
|
||||
href: string = null,
|
||||
options: { center?: boolean } = {}
|
||||
): Promise<Popout> {
|
||||
if (href === null) {
|
||||
href = win.location.href;
|
||||
}
|
||||
|
||||
if (typeof chrome !== "undefined" && chrome.windows && chrome.windows.create) {
|
||||
if (typeof chrome !== "undefined" && chrome?.windows?.create != null) {
|
||||
if (href.indexOf("?uilocation=") > -1) {
|
||||
href = href
|
||||
.replace("uilocation=popup", "uilocation=popout")
|
||||
@@ -63,24 +77,43 @@ export class PopupUtilsService {
|
||||
}
|
||||
|
||||
const bodyRect = document.querySelector("body").getBoundingClientRect();
|
||||
chrome.windows.create({
|
||||
const width = Math.round(bodyRect.width ? bodyRect.width + 60 : 375);
|
||||
const height = Math.round(bodyRect.height || 600);
|
||||
const top = options.center ? Math.round((screen.height - height) / 2) : undefined;
|
||||
const left = options.center ? Math.round((screen.width - width) / 2) : undefined;
|
||||
const window = await BrowserApi.createWindow({
|
||||
url: href,
|
||||
type: "popup",
|
||||
width: Math.round(bodyRect.width ? bodyRect.width + 60 : 375),
|
||||
height: Math.round(bodyRect.height || 600),
|
||||
width,
|
||||
height,
|
||||
top,
|
||||
left,
|
||||
});
|
||||
|
||||
if (this.inPopup(win)) {
|
||||
if (win && this.inPopup(win)) {
|
||||
BrowserApi.closePopup(win);
|
||||
}
|
||||
} else if (typeof chrome !== "undefined" && chrome.tabs && chrome.tabs.create) {
|
||||
|
||||
return { type: "window", window };
|
||||
} else if (chrome?.tabs?.create != null) {
|
||||
href = href
|
||||
.replace("uilocation=popup", "uilocation=tab")
|
||||
.replace("uilocation=popout", "uilocation=tab")
|
||||
.replace("uilocation=sidebar", "uilocation=tab");
|
||||
chrome.tabs.create({
|
||||
url: href,
|
||||
});
|
||||
|
||||
const tab = await BrowserApi.createNewTab(href);
|
||||
return { type: "tab", tab };
|
||||
} else {
|
||||
throw new Error("Cannot open tab or window");
|
||||
}
|
||||
}
|
||||
|
||||
closePopOut(popout: Popout): Promise<void> {
|
||||
switch (popout.type) {
|
||||
case "window":
|
||||
return BrowserApi.removeWindow(popout.window.id);
|
||||
case "tab":
|
||||
return BrowserApi.removeTab(popout.tab.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
21
apps/browser/src/vault/background/abort-manager.ts
Normal file
21
apps/browser/src/vault/background/abort-manager.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
type Runner<T> = (abortController: AbortController) => Promise<T>;
|
||||
|
||||
/**
|
||||
* Manages abort controllers for long running tasks and allow separate
|
||||
* execution contexts to abort each other by using ids.
|
||||
*/
|
||||
export class AbortManager {
|
||||
private abortControllers = new Map<string, AbortController>();
|
||||
|
||||
runWithAbortController<T>(id: string, runner: Runner<T>): Promise<T> {
|
||||
const abortController = new AbortController();
|
||||
this.abortControllers.set(id, abortController);
|
||||
return runner(abortController).finally(() => {
|
||||
this.abortControllers.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
abort(id: string) {
|
||||
this.abortControllers.get(id)?.abort();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import { inject } from "@angular/core";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
EmptyError,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
fromEvent,
|
||||
fromEventPattern,
|
||||
map,
|
||||
merge,
|
||||
Observable,
|
||||
Subject,
|
||||
switchMap,
|
||||
take,
|
||||
takeUntil,
|
||||
throwError,
|
||||
} from "rxjs";
|
||||
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { UserRequestedFallbackAbortReason } from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
|
||||
import {
|
||||
Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction,
|
||||
Fido2UserInterfaceSession,
|
||||
NewCredentialParams,
|
||||
PickCredentialParams,
|
||||
} from "@bitwarden/common/vault/abstractions/fido2/fido2-user-interface.service.abstraction";
|
||||
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
import { BrowserPopoutWindowService } from "../../platform/popup/abstractions/browser-popout-window.service";
|
||||
|
||||
const BrowserFido2MessageName = "BrowserFido2UserInterfaceServiceMessage";
|
||||
|
||||
/**
|
||||
* Function to retrieve FIDO2 session data from query parameters.
|
||||
* Expected to be used within components tied to routes with these query parameters.
|
||||
*/
|
||||
export function fido2PopoutSessionData$() {
|
||||
const route = inject(ActivatedRoute);
|
||||
|
||||
return route.queryParams.pipe(
|
||||
map((queryParams) => ({
|
||||
isFido2Session: queryParams.sessionId != null,
|
||||
sessionId: queryParams.sessionId as string,
|
||||
fallbackSupported: queryParams.fallbackSupported === "true",
|
||||
userVerification: queryParams.userVerification === "true",
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export class SessionClosedError extends Error {
|
||||
constructor() {
|
||||
super("Fido2UserInterfaceSession was closed");
|
||||
}
|
||||
}
|
||||
|
||||
export type BrowserFido2Message = { sessionId: string } & (
|
||||
| /**
|
||||
* This message is used by popouts to announce that they are ready
|
||||
* to recieve messages.
|
||||
**/ {
|
||||
type: "ConnectResponse";
|
||||
}
|
||||
/**
|
||||
* This message is used to announce the creation of a new session.
|
||||
* It is used by popouts to know when to close.
|
||||
**/
|
||||
| {
|
||||
type: "NewSessionCreatedRequest";
|
||||
}
|
||||
| {
|
||||
type: "PickCredentialRequest";
|
||||
cipherIds: string[];
|
||||
userVerification: boolean;
|
||||
fallbackSupported: boolean;
|
||||
}
|
||||
| {
|
||||
type: "PickCredentialResponse";
|
||||
cipherId?: string;
|
||||
userVerified: boolean;
|
||||
}
|
||||
| {
|
||||
type: "ConfirmNewCredentialRequest";
|
||||
credentialName: string;
|
||||
userName: string;
|
||||
userVerification: boolean;
|
||||
fallbackSupported: boolean;
|
||||
}
|
||||
| {
|
||||
type: "ConfirmNewCredentialResponse";
|
||||
cipherId: string;
|
||||
userVerified: boolean;
|
||||
}
|
||||
| {
|
||||
type: "InformExcludedCredentialRequest";
|
||||
existingCipherIds: string[];
|
||||
fallbackSupported: boolean;
|
||||
}
|
||||
| {
|
||||
type: "InformCredentialNotFoundRequest";
|
||||
fallbackSupported: boolean;
|
||||
}
|
||||
| {
|
||||
type: "AbortRequest";
|
||||
}
|
||||
| {
|
||||
type: "AbortResponse";
|
||||
fallbackRequested: boolean;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Browser implementation of the {@link Fido2UserInterfaceService}.
|
||||
* The user interface is implemented as a popout and the service uses the browser's messaging API to communicate with it.
|
||||
*/
|
||||
export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServiceAbstraction {
|
||||
constructor(private browserPopoutWindowService: BrowserPopoutWindowService) {}
|
||||
|
||||
async newSession(
|
||||
fallbackSupported: boolean,
|
||||
tab: chrome.tabs.Tab,
|
||||
abortController?: AbortController
|
||||
): Promise<Fido2UserInterfaceSession> {
|
||||
return await BrowserFido2UserInterfaceSession.create(
|
||||
this.browserPopoutWindowService,
|
||||
fallbackSupported,
|
||||
tab,
|
||||
abortController
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSession {
|
||||
static async create(
|
||||
browserPopoutWindowService: BrowserPopoutWindowService,
|
||||
fallbackSupported: boolean,
|
||||
tab: chrome.tabs.Tab,
|
||||
abortController?: AbortController
|
||||
): Promise<BrowserFido2UserInterfaceSession> {
|
||||
return new BrowserFido2UserInterfaceSession(
|
||||
browserPopoutWindowService,
|
||||
fallbackSupported,
|
||||
tab,
|
||||
abortController
|
||||
);
|
||||
}
|
||||
|
||||
static sendMessage(msg: BrowserFido2Message) {
|
||||
BrowserApi.sendMessage(BrowserFido2MessageName, msg);
|
||||
}
|
||||
|
||||
static abortPopout(sessionId: string, fallbackRequested = false) {
|
||||
this.sendMessage({
|
||||
sessionId: sessionId,
|
||||
type: "AbortResponse",
|
||||
fallbackRequested: fallbackRequested,
|
||||
});
|
||||
}
|
||||
|
||||
static confirmNewCredentialResponse(sessionId: string, cipherId: string, userVerified: boolean) {
|
||||
this.sendMessage({
|
||||
sessionId: sessionId,
|
||||
type: "ConfirmNewCredentialResponse",
|
||||
cipherId,
|
||||
userVerified,
|
||||
});
|
||||
}
|
||||
|
||||
private closed = false;
|
||||
private messages$ = (BrowserApi.messageListener$() as Observable<BrowserFido2Message>).pipe(
|
||||
filter((msg) => msg.sessionId === this.sessionId)
|
||||
);
|
||||
private connected$ = new BehaviorSubject(false);
|
||||
private windowClosed$: Observable<number>;
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
private constructor(
|
||||
private readonly browserPopoutWindowService: BrowserPopoutWindowService,
|
||||
private readonly fallbackSupported: boolean,
|
||||
private readonly tab: chrome.tabs.Tab,
|
||||
readonly abortController = new AbortController(),
|
||||
readonly sessionId = Utils.newGuid()
|
||||
) {
|
||||
this.messages$
|
||||
.pipe(
|
||||
filter((msg) => msg.type === "ConnectResponse"),
|
||||
take(1),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.connected$.next(true);
|
||||
});
|
||||
|
||||
// Handle session aborted by RP
|
||||
fromEvent(abortController.signal, "abort")
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.close();
|
||||
BrowserFido2UserInterfaceSession.sendMessage({
|
||||
type: "AbortRequest",
|
||||
sessionId: this.sessionId,
|
||||
});
|
||||
});
|
||||
|
||||
// Handle session aborted by user
|
||||
this.messages$
|
||||
.pipe(
|
||||
filter((msg) => msg.type === "AbortResponse"),
|
||||
take(1),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe((msg) => {
|
||||
if (msg.type === "AbortResponse") {
|
||||
this.close();
|
||||
this.abort(msg.fallbackRequested);
|
||||
}
|
||||
});
|
||||
|
||||
this.windowClosed$ = fromEventPattern(
|
||||
(handler: any) => chrome.windows.onRemoved.addListener(handler),
|
||||
(handler: any) => chrome.windows.onRemoved.removeListener(handler)
|
||||
);
|
||||
|
||||
BrowserFido2UserInterfaceSession.sendMessage({
|
||||
type: "NewSessionCreatedRequest",
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
async pickCredential({
|
||||
cipherIds,
|
||||
userVerification,
|
||||
}: PickCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
|
||||
const data: BrowserFido2Message = {
|
||||
type: "PickCredentialRequest",
|
||||
cipherIds,
|
||||
sessionId: this.sessionId,
|
||||
userVerification,
|
||||
fallbackSupported: this.fallbackSupported,
|
||||
};
|
||||
|
||||
await this.send(data);
|
||||
const response = await this.receive("PickCredentialResponse");
|
||||
|
||||
return { cipherId: response.cipherId, userVerified: response.userVerified };
|
||||
}
|
||||
|
||||
async confirmNewCredential({
|
||||
credentialName,
|
||||
userName,
|
||||
userVerification,
|
||||
}: NewCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
|
||||
const data: BrowserFido2Message = {
|
||||
type: "ConfirmNewCredentialRequest",
|
||||
sessionId: this.sessionId,
|
||||
credentialName,
|
||||
userName,
|
||||
userVerification,
|
||||
fallbackSupported: this.fallbackSupported,
|
||||
};
|
||||
|
||||
await this.send(data);
|
||||
const response = await this.receive("ConfirmNewCredentialResponse");
|
||||
|
||||
return { cipherId: response.cipherId, userVerified: response.userVerified };
|
||||
}
|
||||
|
||||
async informExcludedCredential(existingCipherIds: string[]): Promise<void> {
|
||||
const data: BrowserFido2Message = {
|
||||
type: "InformExcludedCredentialRequest",
|
||||
sessionId: this.sessionId,
|
||||
existingCipherIds,
|
||||
fallbackSupported: this.fallbackSupported,
|
||||
};
|
||||
|
||||
await this.send(data);
|
||||
await this.receive("AbortResponse");
|
||||
}
|
||||
|
||||
async ensureUnlockedVault(): Promise<void> {
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
async informCredentialNotFound(): Promise<void> {
|
||||
const data: BrowserFido2Message = {
|
||||
type: "InformCredentialNotFoundRequest",
|
||||
sessionId: this.sessionId,
|
||||
fallbackSupported: this.fallbackSupported,
|
||||
};
|
||||
|
||||
await this.send(data);
|
||||
await this.receive("AbortResponse");
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.browserPopoutWindowService.closeFido2Popout();
|
||||
this.closed = true;
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private async abort(fallback = false) {
|
||||
this.abortController.abort(fallback ? UserRequestedFallbackAbortReason : undefined);
|
||||
}
|
||||
|
||||
private async send(msg: BrowserFido2Message): Promise<void> {
|
||||
if (!this.connected$.value) {
|
||||
await this.connect();
|
||||
}
|
||||
BrowserFido2UserInterfaceSession.sendMessage(msg);
|
||||
}
|
||||
|
||||
private async receive<T extends BrowserFido2Message["type"]>(
|
||||
type: T
|
||||
): Promise<BrowserFido2Message & { type: T }> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.messages$.pipe(
|
||||
filter((msg) => msg.sessionId === this.sessionId && msg.type === type),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
);
|
||||
return response as BrowserFido2Message & { type: T };
|
||||
} catch (error) {
|
||||
if (error instanceof EmptyError) {
|
||||
throw new SessionClosedError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
if (this.closed) {
|
||||
throw new Error("Cannot re-open closed session");
|
||||
}
|
||||
|
||||
const connectPromise = firstValueFrom(
|
||||
merge(
|
||||
this.connected$.pipe(filter((connected) => connected === true)),
|
||||
fromEvent(this.abortController.signal, "abort").pipe(
|
||||
switchMap(() => throwError(() => new SessionClosedError()))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const popoutId = await this.browserPopoutWindowService.openFido2Popout(this.tab, {
|
||||
sessionId: this.sessionId,
|
||||
senderTabId: this.tab.id,
|
||||
fallbackSupported: this.fallbackSupported,
|
||||
});
|
||||
|
||||
this.windowClosed$
|
||||
.pipe(
|
||||
filter((windowId) => {
|
||||
return popoutId === windowId;
|
||||
}),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.close();
|
||||
this.abort();
|
||||
});
|
||||
|
||||
await connectPromise;
|
||||
}
|
||||
}
|
||||
81
apps/browser/src/vault/fido2/content/content-script.ts
Normal file
81
apps/browser/src/vault/fido2/content/content-script.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Message, MessageType } from "./messaging/message";
|
||||
import { Messenger } from "./messaging/messenger";
|
||||
|
||||
function checkFido2FeatureEnabled() {
|
||||
chrome.runtime.sendMessage(
|
||||
{ command: "checkFido2FeatureEnabled" },
|
||||
(response: { result?: boolean }) => initializeFido2ContentScript(response.result)
|
||||
);
|
||||
}
|
||||
|
||||
function initializeFido2ContentScript(isFido2FeatureEnabled: boolean) {
|
||||
if (isFido2FeatureEnabled !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const s = document.createElement("script");
|
||||
s.src = chrome.runtime.getURL("content/fido2/page-script.js");
|
||||
(document.head || document.documentElement).appendChild(s);
|
||||
|
||||
const messenger = Messenger.forDOMCommunication(window);
|
||||
|
||||
messenger.handler = async (message, abortController) => {
|
||||
const requestId = Date.now().toString();
|
||||
const abortHandler = () =>
|
||||
chrome.runtime.sendMessage({
|
||||
command: "fido2AbortRequest",
|
||||
abortedRequestId: requestId,
|
||||
});
|
||||
abortController.signal.addEventListener("abort", abortHandler);
|
||||
|
||||
if (message.type === MessageType.CredentialCreationRequest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage(
|
||||
{
|
||||
command: "fido2RegisterCredentialRequest",
|
||||
data: message.data,
|
||||
requestId: requestId,
|
||||
},
|
||||
(response) => {
|
||||
if (response.error !== undefined) {
|
||||
return reject(response.error);
|
||||
}
|
||||
|
||||
resolve({
|
||||
type: MessageType.CredentialCreationResponse,
|
||||
result: response.result,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (message.type === MessageType.CredentialGetRequest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.runtime.sendMessage(
|
||||
{
|
||||
command: "fido2GetCredentialRequest",
|
||||
data: message.data,
|
||||
requestId: requestId,
|
||||
},
|
||||
(response) => {
|
||||
if (response.error !== undefined) {
|
||||
return reject(response.error);
|
||||
}
|
||||
|
||||
resolve({
|
||||
type: MessageType.CredentialGetResponse,
|
||||
result: response.result,
|
||||
});
|
||||
}
|
||||
);
|
||||
}).finally(() =>
|
||||
abortController.signal.removeEventListener("abort", abortHandler)
|
||||
) as Promise<Message>;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
checkFido2FeatureEnabled();
|
||||
60
apps/browser/src/vault/fido2/content/messaging/message.ts
Normal file
60
apps/browser/src/vault/fido2/content/messaging/message.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
CreateCredentialParams,
|
||||
CreateCredentialResult,
|
||||
AssertCredentialParams,
|
||||
AssertCredentialResult,
|
||||
} from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
|
||||
|
||||
export enum MessageType {
|
||||
CredentialCreationRequest,
|
||||
CredentialCreationResponse,
|
||||
CredentialGetRequest,
|
||||
CredentialGetResponse,
|
||||
AbortRequest,
|
||||
AbortResponse,
|
||||
ErrorResponse,
|
||||
}
|
||||
|
||||
export type CredentialCreationRequest = {
|
||||
type: MessageType.CredentialCreationRequest;
|
||||
data: CreateCredentialParams;
|
||||
};
|
||||
|
||||
export type CredentialCreationResponse = {
|
||||
type: MessageType.CredentialCreationResponse;
|
||||
result?: CreateCredentialResult;
|
||||
};
|
||||
|
||||
export type CredentialGetRequest = {
|
||||
type: MessageType.CredentialGetRequest;
|
||||
data: AssertCredentialParams;
|
||||
};
|
||||
|
||||
export type CredentialGetResponse = {
|
||||
type: MessageType.CredentialGetResponse;
|
||||
result?: AssertCredentialResult;
|
||||
};
|
||||
|
||||
export type AbortRequest = {
|
||||
type: MessageType.AbortRequest;
|
||||
abortedRequestId: string;
|
||||
};
|
||||
|
||||
export type ErrorResponse = {
|
||||
type: MessageType.ErrorResponse;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type AbortResponse = {
|
||||
type: MessageType.AbortResponse;
|
||||
abortedRequestId: string;
|
||||
};
|
||||
|
||||
export type Message =
|
||||
| CredentialCreationRequest
|
||||
| CredentialCreationResponse
|
||||
| CredentialGetRequest
|
||||
| CredentialGetResponse
|
||||
| AbortRequest
|
||||
| AbortResponse
|
||||
| ErrorResponse;
|
||||
154
apps/browser/src/vault/fido2/content/messaging/messenger.spec.ts
Normal file
154
apps/browser/src/vault/fido2/content/messaging/messenger.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
import { Message } from "./message";
|
||||
import { Channel, MessageWithMetadata, Messenger } from "./messenger";
|
||||
|
||||
describe("Messenger", () => {
|
||||
let messengerA: Messenger;
|
||||
let messengerB: Messenger;
|
||||
let handlerA: TestMessageHandler;
|
||||
let handlerB: TestMessageHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
// jest does not support MessageChannel
|
||||
window.MessageChannel = MockMessageChannel as any;
|
||||
|
||||
const channelPair = new TestChannelPair();
|
||||
messengerA = new Messenger(channelPair.channelA);
|
||||
messengerB = new Messenger(channelPair.channelB);
|
||||
|
||||
handlerA = new TestMessageHandler();
|
||||
handlerB = new TestMessageHandler();
|
||||
messengerA.handler = handlerA.handler;
|
||||
messengerB.handler = handlerB.handler;
|
||||
});
|
||||
|
||||
it("should deliver message to B when sending request from A", () => {
|
||||
const request = createRequest();
|
||||
messengerA.request(request);
|
||||
|
||||
const received = handlerB.recieve();
|
||||
|
||||
expect(received.length).toBe(1);
|
||||
expect(received[0].message).toMatchObject(request);
|
||||
});
|
||||
|
||||
it("should return response from B when sending request from A", async () => {
|
||||
const request = createRequest();
|
||||
const response = createResponse();
|
||||
const requestPromise = messengerA.request(request);
|
||||
const received = handlerB.recieve();
|
||||
received[0].respond(response);
|
||||
|
||||
const returned = await requestPromise;
|
||||
|
||||
expect(returned).toMatchObject(response);
|
||||
});
|
||||
|
||||
it("should throw error from B when sending request from A that fails", async () => {
|
||||
const request = createRequest();
|
||||
const error = new Error("Test error");
|
||||
const requestPromise = messengerA.request(request);
|
||||
const received = handlerB.recieve();
|
||||
|
||||
received[0].reject(error);
|
||||
|
||||
await expect(requestPromise).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should deliver abort signal to B when requesting abort", () => {
|
||||
const abortController = new AbortController();
|
||||
messengerA.request(createRequest(), abortController);
|
||||
abortController.abort();
|
||||
|
||||
const received = handlerB.recieve();
|
||||
|
||||
expect(received[0].abortController.signal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
type TestMessage = MessageWithMetadata & { testId: string };
|
||||
|
||||
function createRequest(): TestMessage {
|
||||
return { testId: Utils.newGuid(), type: "TestRequest" } as any;
|
||||
}
|
||||
|
||||
function createResponse(): TestMessage {
|
||||
return { testId: Utils.newGuid(), type: "TestResponse" } as any;
|
||||
}
|
||||
|
||||
class TestChannelPair {
|
||||
readonly channelA: Channel;
|
||||
readonly channelB: Channel;
|
||||
|
||||
constructor() {
|
||||
const broadcastChannel = new MockMessageChannel<MessageWithMetadata>();
|
||||
|
||||
this.channelA = {
|
||||
addEventListener: (listener) => (broadcastChannel.port1.onmessage = listener),
|
||||
postMessage: (message, port) => broadcastChannel.port1.postMessage(message, port),
|
||||
};
|
||||
|
||||
this.channelB = {
|
||||
addEventListener: (listener) => (broadcastChannel.port2.onmessage = listener),
|
||||
postMessage: (message, port) => broadcastChannel.port2.postMessage(message, port),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class TestMessageHandler {
|
||||
readonly handler: (
|
||||
message: TestMessage,
|
||||
abortController?: AbortController
|
||||
) => Promise<Message | undefined>;
|
||||
|
||||
private recievedMessages: {
|
||||
message: TestMessage;
|
||||
respond: (response: TestMessage) => void;
|
||||
reject: (error: Error) => void;
|
||||
abortController?: AbortController;
|
||||
}[] = [];
|
||||
|
||||
constructor() {
|
||||
this.handler = (message, abortController) =>
|
||||
new Promise((resolve, reject) => {
|
||||
this.recievedMessages.push({
|
||||
message,
|
||||
abortController,
|
||||
respond: (response) => resolve(response),
|
||||
reject: (error) => reject(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
recieve() {
|
||||
const received = this.recievedMessages;
|
||||
this.recievedMessages = [];
|
||||
return received;
|
||||
}
|
||||
}
|
||||
|
||||
class MockMessageChannel<T> {
|
||||
port1 = new MockMessagePort<T>();
|
||||
port2 = new MockMessagePort<T>();
|
||||
|
||||
constructor() {
|
||||
this.port1.remotePort = this.port2;
|
||||
this.port2.remotePort = this.port1;
|
||||
}
|
||||
}
|
||||
|
||||
class MockMessagePort<T> {
|
||||
onmessage: ((ev: MessageEvent<T>) => any) | null;
|
||||
remotePort: MockMessagePort<T>;
|
||||
|
||||
postMessage(message: T, port?: MessagePort) {
|
||||
this.remotePort.onmessage(
|
||||
new MessageEvent("message", { data: message, ports: port ? [port] : [] })
|
||||
);
|
||||
}
|
||||
|
||||
close() {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
130
apps/browser/src/vault/fido2/content/messaging/messenger.ts
Normal file
130
apps/browser/src/vault/fido2/content/messaging/messenger.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Message, MessageType } from "./message";
|
||||
|
||||
const SENDER = "bitwarden-webauthn";
|
||||
|
||||
type PostMessageFunction = (message: MessageWithMetadata, remotePort: MessagePort) => void;
|
||||
|
||||
export type Channel = {
|
||||
addEventListener: (listener: (message: MessageEvent<MessageWithMetadata>) => void) => void;
|
||||
postMessage: PostMessageFunction;
|
||||
};
|
||||
|
||||
export type Metadata = { SENDER: typeof SENDER };
|
||||
export type MessageWithMetadata = Message & Metadata;
|
||||
type Handler = (
|
||||
message: MessageWithMetadata,
|
||||
abortController?: AbortController
|
||||
) => Promise<Message | undefined>;
|
||||
|
||||
/**
|
||||
* A class that handles communication between the page and content script. It converts
|
||||
* the browser's broadcasting API into a request/response API with support for seamlessly
|
||||
* handling aborts and exceptions across separate execution contexts.
|
||||
*/
|
||||
export class Messenger {
|
||||
/**
|
||||
* Creates a messenger that uses the browser's `window.postMessage` API to initiate
|
||||
* requests in the content script. Every request will then create it's own
|
||||
* `MessageChannel` through which all subsequent communication will be sent through.
|
||||
*
|
||||
* @param window the window object to use for communication
|
||||
* @returns a `Messenger` instance
|
||||
*/
|
||||
static forDOMCommunication(window: Window) {
|
||||
const windowOrigin = window.location.origin;
|
||||
|
||||
return new Messenger({
|
||||
postMessage: (message, port) => window.postMessage(message, windowOrigin, [port]),
|
||||
addEventListener: (listener) =>
|
||||
window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||
if (event.origin !== windowOrigin) {
|
||||
return;
|
||||
}
|
||||
|
||||
listener(event as MessageEvent<MessageWithMetadata>);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The handler that will be called when a message is recieved. The handler should return
|
||||
* a promise that resolves to the response message. If the handler throws an error, the
|
||||
* error will be sent back to the sender.
|
||||
*/
|
||||
handler?: Handler;
|
||||
|
||||
constructor(private broadcastChannel: Channel) {
|
||||
this.broadcastChannel.addEventListener(async (event) => {
|
||||
if (this.handler === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = event.data;
|
||||
const port = event.ports?.[0];
|
||||
if (message?.SENDER !== SENDER || message == null || port == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
port.onmessage = (event: MessageEvent<MessageWithMetadata>) => {
|
||||
if (event.data.type === MessageType.AbortRequest) {
|
||||
abortController.abort();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const handlerResponse = await this.handler(message, abortController);
|
||||
port.postMessage({ ...handlerResponse, SENDER });
|
||||
} catch (error) {
|
||||
port.postMessage({
|
||||
SENDER,
|
||||
type: MessageType.ErrorResponse,
|
||||
error: JSON.stringify(error, Object.getOwnPropertyNames(error)),
|
||||
});
|
||||
} finally {
|
||||
port.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a request to the content script and returns the response.
|
||||
* AbortController signals will be forwarded to the content script.
|
||||
*
|
||||
* @param request data to send to the content script
|
||||
* @param abortController the abort controller that might be used to abort the request
|
||||
* @returns the response from the content script
|
||||
*/
|
||||
async request(request: Message, abortController?: AbortController): Promise<Message> {
|
||||
const requestChannel = new MessageChannel();
|
||||
const { port1: localPort, port2: remotePort } = requestChannel;
|
||||
|
||||
try {
|
||||
const promise = new Promise<Message>((resolve) => {
|
||||
localPort.onmessage = (event: MessageEvent<MessageWithMetadata>) => resolve(event.data);
|
||||
});
|
||||
|
||||
const abortListener = () =>
|
||||
localPort.postMessage({
|
||||
metadata: { SENDER },
|
||||
type: MessageType.AbortRequest,
|
||||
});
|
||||
abortController?.signal.addEventListener("abort", abortListener);
|
||||
|
||||
this.broadcastChannel.postMessage({ ...request, SENDER }, remotePort);
|
||||
const response = await promise;
|
||||
|
||||
abortController?.signal.removeEventListener("abort", abortListener);
|
||||
|
||||
if (response.type === MessageType.ErrorResponse) {
|
||||
const error = new Error();
|
||||
Object.assign(error, JSON.parse(response.error));
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response;
|
||||
} finally {
|
||||
localPort.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
140
apps/browser/src/vault/fido2/content/page-script.ts
Normal file
140
apps/browser/src/vault/fido2/content/page-script.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { FallbackRequestedError } from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
|
||||
|
||||
import { WebauthnUtils } from "../webauthn-utils";
|
||||
|
||||
import { MessageType } from "./messaging/message";
|
||||
import { Messenger } from "./messaging/messenger";
|
||||
|
||||
const BrowserPublicKeyCredential = window.PublicKeyCredential;
|
||||
|
||||
const browserNativeWebauthnSupport = window.PublicKeyCredential != undefined;
|
||||
let browserNativeWebauthnPlatformAuthenticatorSupport = false;
|
||||
if (!browserNativeWebauthnSupport) {
|
||||
// Polyfill webauthn support
|
||||
try {
|
||||
// credentials is read-only if supported, use type-casting to force assignment
|
||||
(navigator as any).credentials = {
|
||||
async create() {
|
||||
throw new Error("Webauthn not supported in this browser.");
|
||||
},
|
||||
async get() {
|
||||
throw new Error("Webauthn not supported in this browser.");
|
||||
},
|
||||
};
|
||||
window.PublicKeyCredential = class PolyfillPublicKeyCredential {
|
||||
static isUserVerifyingPlatformAuthenticatorAvailable() {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
} as any;
|
||||
window.AuthenticatorAttestationResponse =
|
||||
class PolyfillAuthenticatorAttestationResponse {} as any;
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
|
||||
if (browserNativeWebauthnSupport) {
|
||||
BrowserPublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then((available) => {
|
||||
browserNativeWebauthnPlatformAuthenticatorSupport = available;
|
||||
|
||||
if (!available) {
|
||||
// Polyfill platform authenticator support
|
||||
window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable = () =>
|
||||
Promise.resolve(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const browserCredentials = {
|
||||
create: navigator.credentials.create.bind(
|
||||
navigator.credentials
|
||||
) as typeof navigator.credentials.create,
|
||||
get: navigator.credentials.get.bind(navigator.credentials) as typeof navigator.credentials.get,
|
||||
};
|
||||
|
||||
const messenger = Messenger.forDOMCommunication(window);
|
||||
|
||||
function isSameOriginWithAncestors() {
|
||||
try {
|
||||
return window.self === window.top;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
navigator.credentials.create = async (
|
||||
options?: CredentialCreationOptions,
|
||||
abortController?: AbortController
|
||||
): Promise<Credential> => {
|
||||
const fallbackSupported =
|
||||
(options?.publicKey?.authenticatorSelection.authenticatorAttachment === "platform" &&
|
||||
browserNativeWebauthnPlatformAuthenticatorSupport) ||
|
||||
(options?.publicKey?.authenticatorSelection.authenticatorAttachment !== "platform" &&
|
||||
browserNativeWebauthnSupport);
|
||||
try {
|
||||
const isNotIframe = isSameOriginWithAncestors();
|
||||
|
||||
const response = await messenger.request(
|
||||
{
|
||||
type: MessageType.CredentialCreationRequest,
|
||||
data: WebauthnUtils.mapCredentialCreationOptions(
|
||||
options,
|
||||
window.location.origin,
|
||||
isNotIframe,
|
||||
fallbackSupported
|
||||
),
|
||||
},
|
||||
abortController
|
||||
);
|
||||
|
||||
if (response.type !== MessageType.CredentialCreationResponse) {
|
||||
throw new Error("Something went wrong.");
|
||||
}
|
||||
|
||||
return WebauthnUtils.mapCredentialRegistrationResult(response.result);
|
||||
} catch (error) {
|
||||
if (error && error.fallbackRequested && fallbackSupported) {
|
||||
return await browserCredentials.create(options);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
navigator.credentials.get = async (
|
||||
options?: CredentialRequestOptions,
|
||||
abortController?: AbortController
|
||||
): Promise<Credential> => {
|
||||
const fallbackSupported = browserNativeWebauthnSupport;
|
||||
|
||||
try {
|
||||
if (options?.mediation && options.mediation !== "optional") {
|
||||
throw new FallbackRequestedError();
|
||||
}
|
||||
|
||||
const response = await messenger.request(
|
||||
{
|
||||
type: MessageType.CredentialGetRequest,
|
||||
data: WebauthnUtils.mapCredentialRequestOptions(
|
||||
options,
|
||||
window.location.origin,
|
||||
true,
|
||||
fallbackSupported
|
||||
),
|
||||
},
|
||||
abortController
|
||||
);
|
||||
|
||||
if (response.type !== MessageType.CredentialGetResponse) {
|
||||
throw new Error("Something went wrong.");
|
||||
}
|
||||
|
||||
return WebauthnUtils.mapCredentialAssertResult(response.result);
|
||||
} catch (error) {
|
||||
if (error && error.fallbackRequested && fallbackSupported) {
|
||||
return await browserCredentials.get(options);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
141
apps/browser/src/vault/fido2/webauthn-utils.ts
Normal file
141
apps/browser/src/vault/fido2/webauthn-utils.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
CreateCredentialParams,
|
||||
CreateCredentialResult,
|
||||
AssertCredentialParams,
|
||||
AssertCredentialResult,
|
||||
} from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
|
||||
import { Fido2Utils } from "@bitwarden/common/vault/services/fido2/fido2-utils";
|
||||
|
||||
export class WebauthnUtils {
|
||||
static mapCredentialCreationOptions(
|
||||
options: CredentialCreationOptions,
|
||||
origin: string,
|
||||
sameOriginWithAncestors: boolean,
|
||||
fallbackSupported: boolean
|
||||
): CreateCredentialParams {
|
||||
const keyOptions = options.publicKey;
|
||||
|
||||
if (keyOptions == undefined) {
|
||||
throw new Error("Public-key options not found");
|
||||
}
|
||||
|
||||
return {
|
||||
origin,
|
||||
attestation: keyOptions.attestation,
|
||||
authenticatorSelection: {
|
||||
requireResidentKey: keyOptions.authenticatorSelection?.requireResidentKey,
|
||||
residentKey: keyOptions.authenticatorSelection?.residentKey,
|
||||
userVerification: keyOptions.authenticatorSelection?.userVerification,
|
||||
},
|
||||
challenge: Fido2Utils.bufferToString(keyOptions.challenge),
|
||||
excludeCredentials: keyOptions.excludeCredentials?.map((credential) => ({
|
||||
id: Fido2Utils.bufferToString(credential.id),
|
||||
transports: credential.transports,
|
||||
type: credential.type,
|
||||
})),
|
||||
extensions: undefined, // extensions not currently supported
|
||||
pubKeyCredParams: keyOptions.pubKeyCredParams.map((params) => ({
|
||||
alg: params.alg,
|
||||
type: params.type,
|
||||
})),
|
||||
rp: {
|
||||
id: keyOptions.rp.id,
|
||||
name: keyOptions.rp.name,
|
||||
},
|
||||
user: {
|
||||
id: Fido2Utils.bufferToString(keyOptions.user.id),
|
||||
displayName: keyOptions.user.displayName,
|
||||
},
|
||||
timeout: keyOptions.timeout,
|
||||
sameOriginWithAncestors,
|
||||
fallbackSupported,
|
||||
};
|
||||
}
|
||||
|
||||
static mapCredentialRegistrationResult(result: CreateCredentialResult): PublicKeyCredential {
|
||||
const credential = {
|
||||
id: result.credentialId,
|
||||
rawId: Fido2Utils.stringToBuffer(result.credentialId),
|
||||
type: "public-key",
|
||||
authenticatorAttachment: "cross-platform",
|
||||
response: {
|
||||
clientDataJSON: Fido2Utils.stringToBuffer(result.clientDataJSON),
|
||||
attestationObject: Fido2Utils.stringToBuffer(result.attestationObject),
|
||||
|
||||
getAuthenticatorData(): ArrayBuffer {
|
||||
return Fido2Utils.stringToBuffer(result.authData);
|
||||
},
|
||||
|
||||
getPublicKey(): ArrayBuffer {
|
||||
return null;
|
||||
},
|
||||
|
||||
getPublicKeyAlgorithm(): number {
|
||||
return result.publicKeyAlgorithm;
|
||||
},
|
||||
|
||||
getTransports(): string[] {
|
||||
return result.transports;
|
||||
},
|
||||
} as AuthenticatorAttestationResponse,
|
||||
getClientExtensionResults: () => ({}),
|
||||
} as PublicKeyCredential;
|
||||
|
||||
// Modify prototype chains to fix `instanceof` calls.
|
||||
// This makes these objects indistinguishable from the native classes.
|
||||
// Unfortunately PublicKeyCredential does not have a javascript constructor so `extends` does not work here.
|
||||
Object.setPrototypeOf(credential.response, AuthenticatorAttestationResponse.prototype);
|
||||
Object.setPrototypeOf(credential, PublicKeyCredential.prototype);
|
||||
|
||||
return credential;
|
||||
}
|
||||
|
||||
static mapCredentialRequestOptions(
|
||||
options: CredentialRequestOptions,
|
||||
origin: string,
|
||||
sameOriginWithAncestors: boolean,
|
||||
fallbackSupported: boolean
|
||||
): AssertCredentialParams {
|
||||
const keyOptions = options.publicKey;
|
||||
|
||||
if (keyOptions == undefined) {
|
||||
throw new Error("Public-key options not found");
|
||||
}
|
||||
|
||||
return {
|
||||
origin,
|
||||
allowedCredentialIds:
|
||||
keyOptions.allowCredentials?.map((c) => Fido2Utils.bufferToString(c.id)) ?? [],
|
||||
challenge: Fido2Utils.bufferToString(keyOptions.challenge),
|
||||
rpId: keyOptions.rpId,
|
||||
userVerification: keyOptions.userVerification,
|
||||
timeout: keyOptions.timeout,
|
||||
sameOriginWithAncestors,
|
||||
fallbackSupported,
|
||||
};
|
||||
}
|
||||
|
||||
static mapCredentialAssertResult(result: AssertCredentialResult): PublicKeyCredential {
|
||||
const credential = {
|
||||
id: result.credentialId,
|
||||
rawId: Fido2Utils.stringToBuffer(result.credentialId),
|
||||
type: "public-key",
|
||||
response: {
|
||||
authenticatorData: Fido2Utils.stringToBuffer(result.authenticatorData),
|
||||
clientDataJSON: Fido2Utils.stringToBuffer(result.clientDataJSON),
|
||||
signature: Fido2Utils.stringToBuffer(result.signature),
|
||||
userHandle: Fido2Utils.stringToBuffer(result.userHandle),
|
||||
} as AuthenticatorAssertionResponse,
|
||||
getClientExtensionResults: () => ({}),
|
||||
authenticatorAttachment: "cross-platform",
|
||||
} as PublicKeyCredential;
|
||||
|
||||
// Modify prototype chains to fix `instanceof` calls.
|
||||
// This makes these objects indistinguishable from the native classes.
|
||||
// Unfortunately PublicKeyCredential does not have a javascript constructor so `extends` does not work here.
|
||||
Object.setPrototypeOf(credential.response, AuthenticatorAssertionResponse.prototype);
|
||||
Object.setPrototypeOf(credential, PublicKeyCredential.prototype);
|
||||
|
||||
return credential;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<div
|
||||
role="group"
|
||||
appA11yTitle="{{ cipher.name }} {{ cipher.subTitle }}"
|
||||
class="virtual-scroll-item"
|
||||
[ngClass]="{ 'override-last': !last }"
|
||||
>
|
||||
<div class="box-content-row box-content-row-flex">
|
||||
<button
|
||||
type="button"
|
||||
(click)="selectCipher(cipher)"
|
||||
tabindex="0"
|
||||
appStopClick
|
||||
title="{{ title }} - {{ cipher.name }}"
|
||||
[ngClass]="{ 'row-main': true, 'row-selected': isSelected && !isSearching }"
|
||||
>
|
||||
<app-vault-icon [cipher]="cipher"></app-vault-icon>
|
||||
<div class="row-main-content">
|
||||
<span class="text">
|
||||
<span class="truncate-box">
|
||||
<span class="truncate">{{ cipher.name }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="detail" *ngIf="cipher.subTitle">{{ cipher.subTitle }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
@Component({
|
||||
selector: "app-fido2-cipher-row",
|
||||
templateUrl: "fido2-cipher-row.component.html",
|
||||
})
|
||||
export class Fido2CipherRowComponent {
|
||||
@Output() onSelected = new EventEmitter<CipherView>();
|
||||
@Input() cipher: CipherView;
|
||||
@Input() last: boolean;
|
||||
@Input() title: string;
|
||||
@Input() isSearching: boolean;
|
||||
@Input() isSelected: boolean;
|
||||
|
||||
selectCipher(c: CipherView) {
|
||||
this.onSelected.emit(c);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<div class="useBrowserlink" *ngIf="(fido2PopoutSessionData$ | async).fallbackSupported">
|
||||
<button appStopClick type="button" (click)="abort()">
|
||||
{{ "useBrowserName" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import {
|
||||
BrowserFido2UserInterfaceSession,
|
||||
fido2PopoutSessionData$,
|
||||
} from "../../../fido2/browser-fido2-user-interface.service";
|
||||
|
||||
@Component({
|
||||
selector: "app-fido2-use-browser-link",
|
||||
templateUrl: "fido2-use-browser-link.component.html",
|
||||
})
|
||||
export class Fido2UseBrowserLinkComponent {
|
||||
fido2PopoutSessionData$ = fido2PopoutSessionData$();
|
||||
|
||||
async abort() {
|
||||
const sessionData = await firstValueFrom(this.fido2PopoutSessionData$);
|
||||
BrowserFido2UserInterfaceSession.abortPopout(sessionData.sessionId, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<ng-container *ngIf="data$ | async as data">
|
||||
<div class="auth-wrapper">
|
||||
<div class="auth-header">
|
||||
<div class="left">
|
||||
<ng-container *ngIf="data.message.type != 'PickCredentialRequest'">
|
||||
<div class="logo">
|
||||
<i class="bwi bwi-shield"></i>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="data.message.type == 'PickCredentialRequest'">
|
||||
<div class="logo">
|
||||
<i class="bwi bwi-shield"></i><span><strong>bit</strong>warden</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
<ng-container *ngIf="data.message.type == 'ConfirmNewCredentialRequest'">
|
||||
<div class="search">
|
||||
<input
|
||||
type="{{ searchTypeSearch ? 'search' : 'text' }}"
|
||||
placeholder="{{ 'searchVault' | i18n }}"
|
||||
id="search"
|
||||
[(ngModel)]="searchText"
|
||||
(input)="search(200)"
|
||||
autocomplete="off"
|
||||
appAutofocus
|
||||
/>
|
||||
<i class="bwi bwi-search" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="right">
|
||||
<button type="button" (click)="addCipher()" appA11yTitle="{{ 'addItem' | i18n }}">
|
||||
<i class="bwi bwi-plus bwi-lg bwi-fw" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<ng-container>
|
||||
<ng-container
|
||||
*ngIf="
|
||||
data.message.type === 'PickCredentialRequest' ||
|
||||
data.message.type === 'ConfirmNewCredentialRequest'
|
||||
"
|
||||
>
|
||||
<div class="auth-flow">
|
||||
<p class="subtitle" appA11yTitle="{{ subtitleText | i18n }}">
|
||||
{{ subtitleText | i18n }}
|
||||
</p>
|
||||
<!-- Display when ciphers exist -->
|
||||
<ng-container *ngIf="displayedCiphers.length > 0">
|
||||
<div class="box list">
|
||||
<div class="box-content">
|
||||
<app-fido2-cipher-row
|
||||
*ngFor="let cipherItem of displayedCiphers"
|
||||
[cipher]="cipherItem"
|
||||
[isSearching]="searchPending"
|
||||
title="{{ 'passkeyItem' | i18n }}"
|
||||
(onSelected)="selectedPasskey($event)"
|
||||
[isSelected]="cipher === cipherItem"
|
||||
></app-fido2-cipher-row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<button
|
||||
type="submit"
|
||||
(click)="submit()"
|
||||
class="btn primary block"
|
||||
appA11yTitle="{{ credentialText | i18n }}"
|
||||
>
|
||||
<span [hidden]="loading">
|
||||
{{ credentialText | i18n }}
|
||||
</span>
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-lg bwi-spin"
|
||||
[hidden]="!loading"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!displayedCiphers.length">
|
||||
<div class="box">
|
||||
<button
|
||||
type="submit"
|
||||
(click)="saveNewLogin()"
|
||||
class="btn primary block"
|
||||
appA11yTitle="{{ 'savePasskeyNewLogin' | i18n }}"
|
||||
>
|
||||
<span [hidden]="loading">
|
||||
{{ "savePasskeyNewLogin" | i18n }}
|
||||
</span>
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-lg bwi-spin"
|
||||
[hidden]="!loading"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="data.message.type == 'InformExcludedCredentialRequest'">
|
||||
<div class="auth-flow">
|
||||
<p class="subtitle">{{ "passkeyAlreadyExists" | i18n }}</p>
|
||||
<div class="box list">
|
||||
<div class="box-content">
|
||||
<app-fido2-cipher-row
|
||||
*ngFor="let cipherItem of displayedCiphers"
|
||||
[cipher]="cipherItem"
|
||||
title="{{ 'passkeyItem' | i18n }}"
|
||||
(onSelected)="selectedPasskey($event)"
|
||||
[isSelected]="cipher === cipherItem"
|
||||
></app-fido2-cipher-row>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn primary block" (click)="viewPasskey()">
|
||||
<span [hidden]="loading">{{ "viewItem" | i18n }}</span>
|
||||
<i class="bwi bwi-spinner bwi-lg bwi-spin" [hidden]="!loading" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="data.message.type == 'InformCredentialNotFoundRequest'">
|
||||
<div class="auth-flow">
|
||||
<p class="subtitle">{{ "noPasskeysFoundForThisApplication" | i18n }}</p>
|
||||
</div>
|
||||
<button type="button" class="btn primary block" (click)="abort(false)">
|
||||
<span [hidden]="loading">{{ "close" | i18n }}</span>
|
||||
<i class="bwi bwi-spinner bwi-lg bwi-spin" [hidden]="!loading" aria-hidden="true"></i>
|
||||
</button>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<div class="useBrowserlink">
|
||||
<button *ngIf="data.fallbackSupported" appStopClick type="button" (click)="abort(true)">
|
||||
{{ "useBrowserName" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
427
apps/browser/src/vault/popup/components/fido2/fido2.component.ts
Normal file
427
apps/browser/src/vault/popup/components/fido2/fido2.component.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
concatMap,
|
||||
filter,
|
||||
map,
|
||||
Observable,
|
||||
Subject,
|
||||
take,
|
||||
takeUntil,
|
||||
} from "rxjs";
|
||||
|
||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||
import { SettingsService } from "@bitwarden/common/abstractions/settings.service";
|
||||
import { SecureNoteType } from "@bitwarden/common/enums";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
|
||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view";
|
||||
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
||||
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
import { BrowserApi } from "../../../../platform/browser/browser-api";
|
||||
import {
|
||||
BrowserFido2Message,
|
||||
BrowserFido2UserInterfaceSession,
|
||||
} from "../../../fido2/browser-fido2-user-interface.service";
|
||||
|
||||
interface ViewData {
|
||||
message: BrowserFido2Message;
|
||||
fallbackSupported: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "app-fido2",
|
||||
templateUrl: "fido2.component.html",
|
||||
styleUrls: [],
|
||||
})
|
||||
export class Fido2Component implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
private hasSearched = false;
|
||||
private searchTimeout: any = null;
|
||||
private hasLoadedAllCiphers = false;
|
||||
|
||||
protected cipher: CipherView;
|
||||
protected searchTypeSearch = false;
|
||||
protected searchPending = false;
|
||||
protected searchText: string;
|
||||
protected url: string;
|
||||
protected hostname: string;
|
||||
protected data$: Observable<ViewData>;
|
||||
protected sessionId?: string;
|
||||
protected senderTabId?: string;
|
||||
protected ciphers?: CipherView[] = [];
|
||||
protected displayedCiphers?: CipherView[] = [];
|
||||
protected loading = false;
|
||||
protected subtitleText: string;
|
||||
protected credentialText: string;
|
||||
|
||||
private message$ = new BehaviorSubject<BrowserFido2Message>(null);
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private cipherService: CipherService,
|
||||
private passwordRepromptService: PasswordRepromptService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private settingsService: SettingsService,
|
||||
private searchService: SearchService,
|
||||
private logService: LogService,
|
||||
private dialogService: DialogService
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.searchTypeSearch = !this.platformUtilsService.isSafari();
|
||||
|
||||
const queryParams$ = this.activatedRoute.queryParamMap.pipe(
|
||||
take(1),
|
||||
map((queryParamMap) => ({
|
||||
sessionId: queryParamMap.get("sessionId"),
|
||||
senderTabId: queryParamMap.get("senderTabId"),
|
||||
senderUrl: queryParamMap.get("senderUrl"),
|
||||
}))
|
||||
);
|
||||
|
||||
combineLatest([queryParams$, BrowserApi.messageListener$() as Observable<BrowserFido2Message>])
|
||||
.pipe(
|
||||
concatMap(async ([queryParams, message]) => {
|
||||
this.sessionId = queryParams.sessionId;
|
||||
this.senderTabId = queryParams.senderTabId;
|
||||
this.url = queryParams.senderUrl;
|
||||
// For a 'NewSessionCreatedRequest', abort if it doesn't belong to the current session.
|
||||
if (
|
||||
message.type === "NewSessionCreatedRequest" &&
|
||||
message.sessionId !== queryParams.sessionId
|
||||
) {
|
||||
this.abort(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore messages that don't belong to the current session.
|
||||
if (message.sessionId !== queryParams.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "AbortRequest") {
|
||||
this.abort(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show dialog if user account does not have master password
|
||||
if (!(await this.passwordRepromptService.enabled())) {
|
||||
await this.dialogService.openSimpleDialog({
|
||||
title: { key: "featureNotSupported" },
|
||||
content: { key: "passkeyFeatureIsNotImplementedForAccountsWithoutMasterPassword" },
|
||||
acceptButtonText: { key: "ok" },
|
||||
cancelButtonText: null,
|
||||
type: "info",
|
||||
});
|
||||
|
||||
this.abort(true);
|
||||
return;
|
||||
}
|
||||
|
||||
return message;
|
||||
}),
|
||||
filter((message) => !!message),
|
||||
takeUntil(this.destroy$)
|
||||
)
|
||||
.subscribe((message) => {
|
||||
this.message$.next(message);
|
||||
});
|
||||
|
||||
this.data$ = this.message$.pipe(
|
||||
filter((message) => message != undefined),
|
||||
concatMap(async (message) => {
|
||||
switch (message.type) {
|
||||
case "ConfirmNewCredentialRequest": {
|
||||
const equivalentDomains = this.settingsService.getEquivalentDomains(this.url);
|
||||
|
||||
this.ciphers = (await this.cipherService.getAllDecrypted()).filter(
|
||||
(cipher) => cipher.type === CipherType.Login && !cipher.isDeleted
|
||||
);
|
||||
this.displayedCiphers = this.ciphers.filter((cipher) =>
|
||||
cipher.login.matchesUri(this.url, equivalentDomains)
|
||||
);
|
||||
|
||||
if (this.displayedCiphers.length > 0) {
|
||||
this.selectedPasskey(this.displayedCiphers[0]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "PickCredentialRequest": {
|
||||
this.ciphers = await Promise.all(
|
||||
message.cipherIds.map(async (cipherId) => {
|
||||
const cipher = await this.cipherService.get(cipherId);
|
||||
return cipher.decrypt(
|
||||
await this.cipherService.getKeyForCipherKeyDecryption(cipher)
|
||||
);
|
||||
})
|
||||
);
|
||||
this.displayedCiphers = [...this.ciphers];
|
||||
if (this.displayedCiphers.length > 0) {
|
||||
this.selectedPasskey(this.displayedCiphers[0]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "InformExcludedCredentialRequest": {
|
||||
this.ciphers = await Promise.all(
|
||||
message.existingCipherIds.map(async (cipherId) => {
|
||||
const cipher = await this.cipherService.get(cipherId);
|
||||
return cipher.decrypt(
|
||||
await this.cipherService.getKeyForCipherKeyDecryption(cipher)
|
||||
);
|
||||
})
|
||||
);
|
||||
this.displayedCiphers = [...this.ciphers];
|
||||
|
||||
if (this.displayedCiphers.length > 0) {
|
||||
this.selectedPasskey(this.displayedCiphers[0]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.subtitleText =
|
||||
this.displayedCiphers.length > 0
|
||||
? this.getCredentialSubTitleText(message.type)
|
||||
: "noMatchingPasskeyLogin";
|
||||
|
||||
this.credentialText = this.getCredentialButtonText(message.type);
|
||||
return {
|
||||
message,
|
||||
fallbackSupported: "fallbackSupported" in message && message.fallbackSupported,
|
||||
};
|
||||
}),
|
||||
takeUntil(this.destroy$)
|
||||
);
|
||||
|
||||
queryParams$.pipe(takeUntil(this.destroy$)).subscribe((queryParams) => {
|
||||
this.send({
|
||||
sessionId: queryParams.sessionId,
|
||||
type: "ConnectResponse",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async submit() {
|
||||
const data = this.message$.value;
|
||||
if (data?.type === "PickCredentialRequest") {
|
||||
const userVerified = await this.handleUserVerification(data.userVerification, this.cipher);
|
||||
|
||||
this.send({
|
||||
sessionId: this.sessionId,
|
||||
cipherId: this.cipher.id,
|
||||
type: "PickCredentialResponse",
|
||||
userVerified,
|
||||
});
|
||||
} else if (data?.type === "ConfirmNewCredentialRequest") {
|
||||
if (this.cipher.login.hasFido2Credentials) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "overwritePasskey" },
|
||||
content: { key: "overwritePasskeyAlert" },
|
||||
type: "info",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const userVerified = await this.handleUserVerification(data.userVerification, this.cipher);
|
||||
|
||||
this.send({
|
||||
sessionId: this.sessionId,
|
||||
cipherId: this.cipher.id,
|
||||
type: "ConfirmNewCredentialResponse",
|
||||
userVerified,
|
||||
});
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
}
|
||||
|
||||
async saveNewLogin() {
|
||||
const data = this.message$.value;
|
||||
if (data?.type === "ConfirmNewCredentialRequest") {
|
||||
let userVerified = false;
|
||||
if (data.userVerification) {
|
||||
userVerified = await this.passwordRepromptService.showPasswordPrompt();
|
||||
}
|
||||
|
||||
if (!data.userVerification || userVerified) {
|
||||
await this.createNewCipher();
|
||||
}
|
||||
|
||||
this.send({
|
||||
sessionId: this.sessionId,
|
||||
cipherId: this.cipher?.id,
|
||||
type: "ConfirmNewCredentialResponse",
|
||||
userVerified,
|
||||
});
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
}
|
||||
|
||||
getCredentialSubTitleText(messageType: string): string {
|
||||
return messageType == "ConfirmNewCredentialRequest" ? "choosePasskey" : "logInWithPasskey";
|
||||
}
|
||||
|
||||
getCredentialButtonText(messageType: string): string {
|
||||
return messageType == "ConfirmNewCredentialRequest" ? "savePasskey" : "confirm";
|
||||
}
|
||||
|
||||
selectedPasskey(item: CipherView) {
|
||||
this.cipher = item;
|
||||
}
|
||||
|
||||
viewPasskey() {
|
||||
this.router.navigate(["/view-cipher"], {
|
||||
queryParams: {
|
||||
cipherId: this.cipher.id,
|
||||
uilocation: "popout",
|
||||
senderTabId: this.senderTabId,
|
||||
sessionId: this.sessionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
addCipher() {
|
||||
const data = this.message$.value;
|
||||
|
||||
if (data?.type !== "ConfirmNewCredentialRequest") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.router.navigate(["/add-cipher"], {
|
||||
queryParams: {
|
||||
name: Utils.getHostname(this.url),
|
||||
uri: this.url,
|
||||
uilocation: "popout",
|
||||
senderTabId: this.senderTabId,
|
||||
sessionId: this.sessionId,
|
||||
userVerification: data.userVerification,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async loadLoginCiphers() {
|
||||
this.ciphers = (await this.cipherService.getAllDecrypted()).filter(
|
||||
(cipher) => cipher.type === CipherType.Login && !cipher.isDeleted
|
||||
);
|
||||
if (!this.hasLoadedAllCiphers) {
|
||||
this.hasLoadedAllCiphers = !this.searchService.isSearchable(this.searchText);
|
||||
}
|
||||
await this.search(null);
|
||||
}
|
||||
|
||||
async search(timeout: number = null) {
|
||||
this.searchPending = false;
|
||||
if (this.searchTimeout != null) {
|
||||
clearTimeout(this.searchTimeout);
|
||||
}
|
||||
|
||||
if (timeout == null) {
|
||||
this.hasSearched = this.searchService.isSearchable(this.searchText);
|
||||
this.displayedCiphers = await this.searchService.searchCiphers(
|
||||
this.searchText,
|
||||
null,
|
||||
this.ciphers
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.searchPending = true;
|
||||
this.searchTimeout = setTimeout(async () => {
|
||||
this.hasSearched = this.searchService.isSearchable(this.searchText);
|
||||
if (!this.hasLoadedAllCiphers && !this.hasSearched) {
|
||||
await this.loadLoginCiphers();
|
||||
} else {
|
||||
this.displayedCiphers = await this.searchService.searchCiphers(
|
||||
this.searchText,
|
||||
null,
|
||||
this.ciphers
|
||||
);
|
||||
}
|
||||
this.searchPending = false;
|
||||
this.selectedPasskey(this.displayedCiphers[0]);
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
abort(fallback: boolean) {
|
||||
this.unload(fallback);
|
||||
window.close();
|
||||
}
|
||||
|
||||
unload(fallback = false) {
|
||||
this.send({
|
||||
sessionId: this.sessionId,
|
||||
type: "AbortResponse",
|
||||
fallbackRequested: fallback,
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private buildCipher() {
|
||||
this.cipher = new CipherView();
|
||||
this.cipher.name = Utils.getHostname(this.url);
|
||||
this.cipher.type = CipherType.Login;
|
||||
this.cipher.login = new LoginView();
|
||||
this.cipher.login.uris = [new LoginUriView()];
|
||||
this.cipher.login.uris[0].uri = this.url;
|
||||
this.cipher.card = new CardView();
|
||||
this.cipher.identity = new IdentityView();
|
||||
this.cipher.secureNote = new SecureNoteView();
|
||||
this.cipher.secureNote.type = SecureNoteType.Generic;
|
||||
this.cipher.reprompt = CipherRepromptType.None;
|
||||
}
|
||||
|
||||
private async createNewCipher() {
|
||||
this.buildCipher();
|
||||
const cipher = await this.cipherService.encrypt(this.cipher);
|
||||
try {
|
||||
await this.cipherService.createWithServer(cipher);
|
||||
this.cipher.id = cipher.id;
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleUserVerification(
|
||||
userVerification: boolean,
|
||||
cipher: CipherView
|
||||
): Promise<boolean> {
|
||||
const masterPasswordRepromptRequiered = cipher && cipher.reprompt !== 0;
|
||||
const verificationRequired = userVerification || masterPasswordRepromptRequiered;
|
||||
|
||||
if (!verificationRequired) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await this.passwordRepromptService.showPasswordPrompt();
|
||||
}
|
||||
|
||||
private send(msg: BrowserFido2Message) {
|
||||
BrowserFido2UserInterfaceSession.sendMessage({
|
||||
sessionId: this.sessionId,
|
||||
...msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,18 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--Passkey-->
|
||||
<div class="box" *ngIf="cipher.login.hasFido2Credentials && !cloneMode">
|
||||
<div class="box-content">
|
||||
<div class="box-content-row text-muted">
|
||||
<span class="row-label">{{ "typePasskey" | i18n }}</span>
|
||||
{{ "dateCreated" | i18n }}
|
||||
{{ cipher.login.fido2Credentials[0].creationDate | date : "short" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box-content-row" appBoxRow>
|
||||
<label for="loginTotp">{{ "authenticatorKeyTotp" | i18n }}</label>
|
||||
<input
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Location } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { first } from "rxjs/operators";
|
||||
|
||||
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/vault/components/add-edit.component";
|
||||
@@ -24,6 +25,10 @@ import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
import { BrowserApi } from "../../../../platform/browser/browser-api";
|
||||
import { PopupUtilsService } from "../../../../popup/services/popup-utils.service";
|
||||
import {
|
||||
BrowserFido2UserInterfaceSession,
|
||||
fido2PopoutSessionData$,
|
||||
} from "../../../fido2/browser-fido2-user-interface.service";
|
||||
|
||||
@Component({
|
||||
selector: "app-vault-add-edit",
|
||||
@@ -39,6 +44,8 @@ export class AddEditComponent extends BaseAddEditComponent {
|
||||
uilocation?: "popout" | "popup" | "sidebar" | "tab";
|
||||
inPopout = false;
|
||||
|
||||
private fido2PopoutSessionData$ = fido2PopoutSessionData$();
|
||||
|
||||
constructor(
|
||||
cipherService: CipherService,
|
||||
folderService: FolderService,
|
||||
@@ -159,11 +166,33 @@ export class AddEditComponent extends BaseAddEditComponent {
|
||||
}
|
||||
|
||||
async submit(): Promise<boolean> {
|
||||
const fido2SessionData = await firstValueFrom(this.fido2PopoutSessionData$);
|
||||
// Would be refactored after rework is done on the windows popout service
|
||||
if (
|
||||
this.inPopout &&
|
||||
fido2SessionData.isFido2Session &&
|
||||
!(await this.handleFido2UserVerification(
|
||||
fido2SessionData.sessionId,
|
||||
fido2SessionData.userVerification
|
||||
))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const success = await super.submit();
|
||||
if (!success) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.inPopout && fido2SessionData.isFido2Session) {
|
||||
BrowserFido2UserInterfaceSession.confirmNewCredentialResponse(
|
||||
fido2SessionData.sessionId,
|
||||
this.cipher.id,
|
||||
fido2SessionData.userVerification
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.popupUtilsService.inTab(window)) {
|
||||
this.popupUtilsService.disableCloseTabWarning();
|
||||
this.messagingService.send("closeTab", { delay: 1000 });
|
||||
@@ -204,9 +233,16 @@ export class AddEditComponent extends BaseAddEditComponent {
|
||||
}
|
||||
}
|
||||
|
||||
cancel() {
|
||||
async cancel() {
|
||||
super.cancel();
|
||||
|
||||
// Would be refactored after rework is done on the windows popout service
|
||||
const sessionData = await firstValueFrom(this.fido2PopoutSessionData$);
|
||||
if (this.inPopout && sessionData.isFido2Session) {
|
||||
BrowserFido2UserInterfaceSession.abortPopout(sessionData.sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.senderTabId && this.inPopout) {
|
||||
this.close();
|
||||
return;
|
||||
@@ -291,6 +327,18 @@ export class AddEditComponent extends BaseAddEditComponent {
|
||||
}, 200);
|
||||
}
|
||||
|
||||
private async handleFido2UserVerification(
|
||||
sessionId: string,
|
||||
userVerification: boolean
|
||||
): Promise<boolean> {
|
||||
if (userVerification && !(await this.passwordRepromptService.showPasswordPrompt())) {
|
||||
BrowserFido2UserInterfaceSession.abortPopout(sessionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
repromptChanged() {
|
||||
super.repromptChanged();
|
||||
|
||||
|
||||
@@ -70,7 +70,9 @@
|
||||
<div class="icon"><i class="bwi bwi-fw bwi-lg bwi-globe"></i></div>
|
||||
<span class="text">{{ "typeLogin" | i18n }}</span>
|
||||
</div>
|
||||
<span class="row-sub-label">{{ typeCounts.get(cipherType.Login) || 0 }}</span>
|
||||
<span class="row-sub-label">
|
||||
{{ typeCounts.get(cipherType.Login) || 0 }}
|
||||
</span>
|
||||
<span><i class="bwi bwi-angle-right bwi-lg row-sub-icon"></i></span>
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -142,6 +142,18 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--Passkey-->
|
||||
<div class="box" *ngIf="cipher.login.hasFido2Credentials">
|
||||
<div class="box-content">
|
||||
<div class="box-content-row text-muted">
|
||||
<span class="row-label">{{ "typePasskey" | i18n }}</span>
|
||||
{{ "dateCreated" | i18n }}
|
||||
{{ cipher.login.fido2Credentials[0].creationDate | date : "short" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="box-content-row box-content-row-flex totp"
|
||||
[ngClass]="{ low: totpLow }"
|
||||
@@ -190,7 +202,6 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box-content-row box-content-row-flex totp" *ngIf="showPremiumRequiredTotp">
|
||||
<div class="row-main">
|
||||
<span class="row-label">{{ "verificationCodeTotp" | i18n }}</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Location } from "@angular/common";
|
||||
import { ChangeDetectorRef, Component, NgZone } from "@angular/core";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
import { Subject, firstValueFrom, takeUntil } from "rxjs";
|
||||
import { first } from "rxjs/operators";
|
||||
|
||||
import { ViewComponent as BaseViewComponent } from "@bitwarden/angular/vault/components/view.component";
|
||||
@@ -29,6 +29,10 @@ import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
import { AutofillService } from "../../../../autofill/services/abstractions/autofill.service";
|
||||
import { BrowserApi } from "../../../../platform/browser/browser-api";
|
||||
import { PopupUtilsService } from "../../../../popup/services/popup-utils.service";
|
||||
import {
|
||||
BrowserFido2UserInterfaceSession,
|
||||
fido2PopoutSessionData$,
|
||||
} from "../../../fido2/browser-fido2-user-interface.service";
|
||||
|
||||
const BroadcasterSubscriptionId = "ChildViewComponent";
|
||||
|
||||
@@ -57,6 +61,7 @@ export class ViewComponent extends BaseViewComponent {
|
||||
loadPageDetailsTimeout: number;
|
||||
inPopout = false;
|
||||
cipherType = CipherType;
|
||||
private fido2PopoutSessionData$ = fido2PopoutSessionData$();
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
@@ -301,7 +306,14 @@ export class ViewComponent extends BaseViewComponent {
|
||||
return false;
|
||||
}
|
||||
|
||||
close() {
|
||||
async close() {
|
||||
// Would be refactored after rework is done on the windows popout service
|
||||
const sessionData = await firstValueFrom(this.fido2PopoutSessionData$);
|
||||
if (this.inPopout && sessionData.isFido2Session) {
|
||||
BrowserFido2UserInterfaceSession.abortPopout(sessionData.sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.inPopout && this.senderTabId) {
|
||||
BrowserApi.focusTab(this.senderTabId);
|
||||
window.close();
|
||||
|
||||
@@ -160,6 +160,8 @@ const mainConfig = {
|
||||
"content/notificationBar": "./src/autofill/content/notification-bar.ts",
|
||||
"content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts",
|
||||
"content/message_handler": "./src/autofill/content/message_handler.ts",
|
||||
"content/fido2/content-script": "./src/vault/fido2/content/content-script.ts",
|
||||
"content/fido2/page-script": "./src/vault/fido2/content/page-script.ts",
|
||||
"notification/bar": "./src/autofill/notification/bar.ts",
|
||||
"encrypt-worker": "../../libs/common/src/platform/services/cryptography/encrypt.worker.ts",
|
||||
},
|
||||
|
||||
BIN
apps/desktop/src/images/bwi-passkey.png
Normal file
BIN
apps/desktop/src/images/bwi-passkey.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -2416,6 +2416,15 @@
|
||||
"submenu": {
|
||||
"message": "Submenu"
|
||||
},
|
||||
"typePasskey": {
|
||||
"message": "Passkey"
|
||||
},
|
||||
"passkeyNotCopied": {
|
||||
"message": "Passkey will not be copied"
|
||||
},
|
||||
"passkeyNotCopiedAlert": {
|
||||
"message": "The passkey will not be copied to the cloned item. Do you want to continue cloning this item?"
|
||||
},
|
||||
"aliasDomain": {
|
||||
"message": "Alias domain"
|
||||
}
|
||||
|
||||
@@ -114,6 +114,17 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!--Passkey-->
|
||||
<div
|
||||
class="box-content-row text-muted"
|
||||
*ngIf="cipher.login.hasFido2Credentials && !cloneMode"
|
||||
appBoxRow
|
||||
>
|
||||
<span class="row-label">{{ "typePasskey" | i18n }}</span>
|
||||
{{ "dateCreated" | i18n }}
|
||||
{{ cipher.login.fido2Credentials[0].creationDate | date : "short" }}
|
||||
</div>
|
||||
|
||||
<div class="box-content-row" appBoxRow>
|
||||
<label for="loginTotp">{{ "authenticatorKeyTotp" | i18n }}</label>
|
||||
<input
|
||||
|
||||
@@ -118,6 +118,12 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!--Passkey-->
|
||||
<div class="box-content-row text-muted" *ngIf="cipher.login.hasFido2Credentials">
|
||||
<span class="row-label">{{ "typePasskey" | i18n }}</span>
|
||||
{{ "dateCreated" | i18n }}
|
||||
{{ cipher.login.fido2Credentials[0].creationDate | date : "short" }}
|
||||
</div>
|
||||
<div
|
||||
class="box-content-row box-content-row-flex totp"
|
||||
[ngClass]="{ low: totpLow }"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, NgZone, OnDestroy, OnInit } from "@angular/core";
|
||||
import { Component, NgZone, OnInit } from "@angular/core";
|
||||
import { FormBuilder } from "@angular/forms";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
import { takeUntil } from "rxjs";
|
||||
import { first } from "rxjs/operators";
|
||||
|
||||
import { LoginComponent as BaseLoginComponent } from "@bitwarden/angular/auth/components/login.component";
|
||||
@@ -34,14 +34,13 @@ import { RouterService, StateService } from "../../core";
|
||||
selector: "app-login",
|
||||
templateUrl: "login.component.html",
|
||||
})
|
||||
export class LoginComponent extends BaseLoginComponent implements OnInit, OnDestroy {
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
export class LoginComponent extends BaseLoginComponent implements OnInit {
|
||||
showResetPasswordAutoEnrollWarning = false;
|
||||
enforcedPasswordPolicyOptions: MasterPasswordPolicyOptions;
|
||||
policies: ListResponse<PolicyResponse>;
|
||||
showPasswordless = false;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
devicesApiService: DevicesApiServiceAbstraction,
|
||||
appIdService: AppIdService,
|
||||
@@ -146,11 +145,6 @@ export class LoginComponent extends BaseLoginComponent implements OnInit, OnDest
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
async goAfterLogIn() {
|
||||
const masterPassword = this.formGroup.value.masterPassword;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DatePipe } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
@@ -47,7 +48,8 @@ export class EmergencyAddEditComponent extends BaseAddEditComponent {
|
||||
organizationService: OrganizationService,
|
||||
logService: LogService,
|
||||
sendApiService: SendApiService,
|
||||
dialogService: DialogService
|
||||
dialogService: DialogService,
|
||||
datePipe: DatePipe
|
||||
) {
|
||||
super(
|
||||
cipherService,
|
||||
@@ -66,7 +68,8 @@ export class EmergencyAddEditComponent extends BaseAddEditComponent {
|
||||
logService,
|
||||
passwordRepromptService,
|
||||
sendApiService,
|
||||
dialogService
|
||||
dialogService,
|
||||
datePipe
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
{{ "launch" | i18n }}
|
||||
</a>
|
||||
</ng-container>
|
||||
|
||||
<button bitMenuItem type="button" (click)="attachments()">
|
||||
<i class="bwi bwi-fw bwi-paperclip" aria-hidden="true"></i>
|
||||
{{ "attachments" | i18n }}
|
||||
|
||||
@@ -89,10 +89,7 @@
|
||||
[showGroups]="showGroups"
|
||||
[showPremiumFeatures]="showPremiumFeatures"
|
||||
[useEvents]="useEvents"
|
||||
[cloneable]="
|
||||
(item.cipher.organizationId && cloneableOrganizationCiphers) ||
|
||||
item.cipher.organizationId == null
|
||||
"
|
||||
[cloneable]="canClone(item)"
|
||||
[organizations]="allOrganizations"
|
||||
[collections]="allCollections"
|
||||
[checked]="selection.isSelected(item)"
|
||||
|
||||
@@ -148,6 +148,13 @@ export class VaultItemsComponent {
|
||||
});
|
||||
}
|
||||
|
||||
protected canClone(vaultItem: VaultItem) {
|
||||
return (
|
||||
(vaultItem.cipher.organizationId && this.cloneableOrganizationCiphers) ||
|
||||
vaultItem.cipher.organizationId == null
|
||||
);
|
||||
}
|
||||
|
||||
private refreshItems() {
|
||||
const collections: VaultItem[] = this.collections.map((collection) => ({ collection }));
|
||||
const ciphers: VaultItem[] = this.ciphers.map((cipher) => ({ cipher }));
|
||||
|
||||
@@ -191,6 +191,25 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ng-container *ngIf="cipher.login.hasFido2Credentials">
|
||||
<div class="row">
|
||||
<div class="col-6 form-group">
|
||||
<label for="loginFido2credential">{{ "typePasskey" | i18n }}</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
id="loginFido2credential"
|
||||
class="form-control"
|
||||
type="text"
|
||||
name="Login.Fido2credential"
|
||||
[value]="fido2CredentialCreationDateValue"
|
||||
appInputVerbatim
|
||||
disabled
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="tw-flex tw-flex-row">
|
||||
<div class="tw-mb-4 tw-w-1/2">
|
||||
<label for="loginTotp">{{ "authenticatorKeyTotp" | i18n }}</label>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DatePipe } from "@angular/common";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
|
||||
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/vault/components/add-edit.component";
|
||||
@@ -18,7 +19,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
|
||||
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
|
||||
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
||||
import { Launchable } from "@bitwarden/common/vault/interfaces/launchable";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
@@ -42,6 +43,15 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
|
||||
protected totpInterval: number;
|
||||
protected override componentName = "app-vault-add-edit";
|
||||
|
||||
get fido2CredentialCreationDateValue(): string {
|
||||
const dateCreated = this.i18nService.t("dateCreated");
|
||||
const creationDate = this.datePipe.transform(
|
||||
this.cipher?.login?.fido2Credentials?.[0]?.creationDate,
|
||||
"short"
|
||||
);
|
||||
return `${dateCreated} ${creationDate}`;
|
||||
}
|
||||
|
||||
constructor(
|
||||
cipherService: CipherService,
|
||||
folderService: FolderService,
|
||||
@@ -59,7 +69,8 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
|
||||
logService: LogService,
|
||||
passwordRepromptService: PasswordRepromptService,
|
||||
sendApiService: SendApiService,
|
||||
dialogService: DialogService
|
||||
dialogService: DialogService,
|
||||
private datePipe: DatePipe
|
||||
) {
|
||||
super(
|
||||
cipherService,
|
||||
@@ -131,7 +142,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
|
||||
}
|
||||
}
|
||||
|
||||
launch(uri: LoginUriView) {
|
||||
launch(uri: Launchable) {
|
||||
if (!uri.canLaunch) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -721,6 +721,18 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
async cloneCipher(cipher: CipherView) {
|
||||
if (cipher.login?.hasFido2Credentials) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "passkeyNotCopied" },
|
||||
content: { key: "passkeyNotCopiedAlert" },
|
||||
type: "info",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const component = await this.editCipher(cipher);
|
||||
component.cloneMode = true;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DatePipe } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
@@ -49,7 +50,8 @@ export class AddEditComponent extends BaseAddEditComponent {
|
||||
passwordRepromptService: PasswordRepromptService,
|
||||
organizationService: OrganizationService,
|
||||
sendApiService: SendApiService,
|
||||
dialogService: DialogService
|
||||
dialogService: DialogService,
|
||||
datePipe: DatePipe
|
||||
) {
|
||||
super(
|
||||
cipherService,
|
||||
@@ -68,7 +70,8 @@ export class AddEditComponent extends BaseAddEditComponent {
|
||||
logService,
|
||||
passwordRepromptService,
|
||||
sendApiService,
|
||||
dialogService
|
||||
dialogService,
|
||||
datePipe
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -644,6 +644,18 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
async cloneCipher(cipher: CipherView) {
|
||||
if (cipher.login?.hasFido2Credentials) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "passkeyNotCopied" },
|
||||
content: { key: "passkeyNotCopiedAlert" },
|
||||
type: "info",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const collections = (await firstValueFrom(this.vaultFilterService.filteredCollections$)).filter(
|
||||
(c) => !c.readOnly && c.id != Unassigned
|
||||
);
|
||||
|
||||
BIN
apps/web/src/images/bwi-passkey.png
Normal file
BIN
apps/web/src/images/bwi-passkey.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -7277,5 +7277,14 @@
|
||||
},
|
||||
"customBillingEnd": {
|
||||
"message": " page for latest invoicing."
|
||||
},
|
||||
"typePasskey": {
|
||||
"message": "Passkey"
|
||||
},
|
||||
"passkeyNotCopied": {
|
||||
"message": "Passkey will not be copied"
|
||||
},
|
||||
"passkeyNotCopiedAlert": {
|
||||
"message": "The passkey will not be copied to the cloned item. Do you want to continue cloning this item?"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user