From ba7a211f0dd77dd6d41f11c342912e27a6d14bb8 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Tue, 17 Oct 2023 21:34:44 +0200 Subject: [PATCH] [PM-1222] Store passkeys in Bitwarden vault (#4715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [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 * Fix formatting --------- Co-authored-by: Cesar Gonzalez * [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 * [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 * [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 commit 1f5499b9bbba27c869e1e328c9f819754661ea95. * Revert "refactor fido2-client.service. created new errorhandling method for similar code between create and assert" This reverts commit 3115c0d2a16eafbf89958dc0084cec88f4573b45. * updated test cases * removed forward slashes --------- Co-authored-by: gbubemismith * [EC-598] Window Messaging Fix; (#6223) Co-authored-by: Cesar Gonzalez Co-authored-by: SmithThe4th * 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 * [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 commit 2ca241a0d41aeb089c566df8cbc695521ddb10e0) * Fixed PR comments * Fixed PR comments * Revert "[PM-3783] feat: patch `chrome.runtime.onMessage` event listeners" This reverts commit ed6a713688c06586458f7da0cf51f74bc82b5abc. --------- Co-authored-by: Thomas Rittson Co-authored-by: Andreas Coroiu * [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 * 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 * [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 Co-authored-by: Andreas Coroiu Co-authored-by: jng * 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 commit f72d6f877f76b5c42b449208e43a61a1e5099304. * 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 commit ce5fc9c278b67df3ca2afc28e181d94f22fbc667. * 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 Co-authored-by: SmithThe4th Co-authored-by: Robyn MacCallum Co-authored-by: Cesar Gonzalez Co-authored-by: Oscar Hinton Co-authored-by: Carlos Gonçalves Co-authored-by: Jason Ng Co-authored-by: Todd Martin <106564991+trmartin4@users.noreply.github.com> Co-authored-by: Cesar Gonzalez Co-authored-by: Thomas Rittson Co-authored-by: Todd Martin --- apps/browser/src/_locales/en/messages.json | 54 ++ .../src/auth/guards/fido2-auth.guard.ts | 34 + .../src/auth/popup/lock.component.html | 154 ++-- apps/browser/src/auth/popup/lock.component.ts | 15 +- .../background/context-menus.background.ts | 15 +- .../background/notification.background.ts | 4 +- .../src/background/commands.background.ts | 14 +- .../browser/src/background/main.background.ts | 29 +- .../background/nativeMessaging.background.ts | 2 +- .../src/background/runtime.background.ts | 36 +- apps/browser/src/manifest.json | 3 +- apps/browser/src/manifest.v3.json | 7 +- .../src/platform/browser/browser-api.ts | 41 +- .../session-sync-observable/session-syncer.ts | 7 +- .../browser-popout-window.service.ts | 9 + .../popup/browser-popout-window.service.ts | 52 +- .../popup/services/browser-router.service.ts | 37 + apps/browser/src/popup/app-routing.module.ts | 10 +- apps/browser/src/popup/app.component.ts | 19 +- apps/browser/src/popup/app.module.ts | 6 + apps/browser/src/popup/images/bwi-passkey.png | Bin 0 -> 1078 bytes apps/browser/src/popup/scss/pages.scss | 175 ++++ .../src/popup/services/popup-utils.service.ts | 53 +- .../src/vault/background/abort-manager.ts | 21 + .../browser-fido2-user-interface.service.ts | 366 ++++++++ .../src/vault/fido2/content/content-script.ts | 81 ++ .../vault/fido2/content/messaging/message.ts | 60 ++ .../fido2/content/messaging/messenger.spec.ts | 154 ++++ .../fido2/content/messaging/messenger.ts | 130 +++ .../src/vault/fido2/content/page-script.ts | 140 +++ .../browser/src/vault/fido2/webauthn-utils.ts | 141 +++ .../fido2/fido2-cipher-row.component.html | 27 + .../fido2/fido2-cipher-row.component.ts | 20 + .../fido2-use-browser-link.component.html | 5 + .../fido2/fido2-use-browser-link.component.ts | 21 + .../components/fido2/fido2.component.html | 139 +++ .../popup/components/fido2/fido2.component.ts | 427 +++++++++ .../components/vault/add-edit.component.html | 12 + .../components/vault/add-edit.component.ts | 50 +- .../vault/vault-filter.component.html | 4 +- .../components/vault/view.component.html | 13 +- .../popup/components/vault/view.component.ts | 16 +- apps/browser/webpack.config.js | 2 + apps/desktop/src/images/bwi-passkey.png | Bin 0 -> 1078 bytes apps/desktop/src/locales/en/messages.json | 9 + .../vault/app/vault/add-edit.component.html | 11 + .../src/vault/app/vault/view.component.html | 6 + .../web/src/app/auth/login/login.component.ts | 14 +- .../emergency-add-edit.component.ts | 7 +- .../vault-cipher-row.component.html | 1 + .../vault-items/vault-items.component.html | 5 +- .../vault-items/vault-items.component.ts | 7 + .../individual-vault/add-edit.component.html | 19 + .../individual-vault/add-edit.component.ts | 17 +- .../vault/individual-vault/vault.component.ts | 12 + .../app/vault/org-vault/add-edit.component.ts | 7 +- .../app/vault/org-vault/vault.component.ts | 12 + apps/web/src/images/bwi-passkey.png | Bin 0 -> 1078 bytes apps/web/src/locales/en/messages.json | 9 + .../src/auth/components/login.component.ts | 33 +- .../src/scss/bwicons/fonts/bwi-font.svg | 10 +- .../src/scss/bwicons/fonts/bwi-font.ttf | Bin 75260 -> 76108 bytes .../src/scss/bwicons/fonts/bwi-font.woff | Bin 75336 -> 76184 bytes .../src/scss/bwicons/fonts/bwi-font.woff2 | Bin 33048 -> 33420 bytes .../src/scss/bwicons/styles/style.scss | 10 +- .../vault/components/add-edit.component.ts | 7 +- .../src/vault/components/view.component.ts | 17 +- libs/common/src/enums/feature-flag.enum.ts | 1 + libs/common/src/models/api/login.api.ts | 11 + .../models/export/fido2-credential.export.ts | 125 +++ libs/common/src/models/export/index.ts | 1 + libs/common/src/models/export/login.export.ts | 12 + ...fido2-authenticator.service.abstraction.ts | 143 +++ .../fido2/fido2-client.service.abstraction.ts | 174 ++++ ...ido2-user-interface.service.abstraction.ts | 103 +++ .../src/vault/api/fido2-credential.api.ts | 36 + .../common/src/vault/interfaces/launchable.ts | 4 + .../models/data/fido2-credential.data.ts | 35 + .../src/vault/models/data/login.data.ts | 6 + .../models/domain/fido2-credential.spec.ts | 167 ++++ .../vault/models/domain/fido2-credential.ts | 146 ++++ .../src/vault/models/domain/login.spec.ts | 86 ++ libs/common/src/vault/models/domain/login.ts | 23 +- .../vault/models/request/cipher.request.ts | 26 + .../models/view/fido2-credential.view.ts | 29 + .../src/vault/models/view/login.view.ts | 12 +- .../src/vault/services/cipher.service.ts | 33 + libs/common/src/vault/services/fido2/cbor.ts | 494 +++++++++++ .../vault/services/fido2/domain-utils.spec.ts | 53 ++ .../src/vault/services/fido2/domain-utils.ts | 11 + .../src/vault/services/fido2/ecdsa-utils.ts | 124 +++ .../fido2/fido2-authenticator.service.spec.ts | 826 ++++++++++++++++++ .../fido2/fido2-authenticator.service.ts | 545 ++++++++++++ .../fido2/fido2-client.service.spec.ts | 463 ++++++++++ .../services/fido2/fido2-client.service.ts | 409 +++++++++ .../src/vault/services/fido2/fido2-utils.ts | 26 + .../src/vault/services/fido2/guid-utils.ts | 95 ++ .../noop-fido2-user-interface.service.ts | 14 + package-lock.json | 6 + 99 files changed, 6871 insertions(+), 186 deletions(-) create mode 100644 apps/browser/src/auth/guards/fido2-auth.guard.ts create mode 100644 apps/browser/src/platform/popup/services/browser-router.service.ts create mode 100644 apps/browser/src/popup/images/bwi-passkey.png create mode 100644 apps/browser/src/vault/background/abort-manager.ts create mode 100644 apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts create mode 100644 apps/browser/src/vault/fido2/content/content-script.ts create mode 100644 apps/browser/src/vault/fido2/content/messaging/message.ts create mode 100644 apps/browser/src/vault/fido2/content/messaging/messenger.spec.ts create mode 100644 apps/browser/src/vault/fido2/content/messaging/messenger.ts create mode 100644 apps/browser/src/vault/fido2/content/page-script.ts create mode 100644 apps/browser/src/vault/fido2/webauthn-utils.ts create mode 100644 apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.html create mode 100644 apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.ts create mode 100644 apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.html create mode 100644 apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.ts create mode 100644 apps/browser/src/vault/popup/components/fido2/fido2.component.html create mode 100644 apps/browser/src/vault/popup/components/fido2/fido2.component.ts create mode 100644 apps/desktop/src/images/bwi-passkey.png create mode 100644 apps/web/src/images/bwi-passkey.png create mode 100644 libs/common/src/models/export/fido2-credential.export.ts create mode 100644 libs/common/src/vault/abstractions/fido2/fido2-authenticator.service.abstraction.ts create mode 100644 libs/common/src/vault/abstractions/fido2/fido2-client.service.abstraction.ts create mode 100644 libs/common/src/vault/abstractions/fido2/fido2-user-interface.service.abstraction.ts create mode 100644 libs/common/src/vault/api/fido2-credential.api.ts create mode 100644 libs/common/src/vault/interfaces/launchable.ts create mode 100644 libs/common/src/vault/models/data/fido2-credential.data.ts create mode 100644 libs/common/src/vault/models/domain/fido2-credential.spec.ts create mode 100644 libs/common/src/vault/models/domain/fido2-credential.ts create mode 100644 libs/common/src/vault/models/view/fido2-credential.view.ts create mode 100644 libs/common/src/vault/services/fido2/cbor.ts create mode 100644 libs/common/src/vault/services/fido2/domain-utils.spec.ts create mode 100644 libs/common/src/vault/services/fido2/domain-utils.ts create mode 100644 libs/common/src/vault/services/fido2/ecdsa-utils.ts create mode 100644 libs/common/src/vault/services/fido2/fido2-authenticator.service.spec.ts create mode 100644 libs/common/src/vault/services/fido2/fido2-authenticator.service.ts create mode 100644 libs/common/src/vault/services/fido2/fido2-client.service.spec.ts create mode 100644 libs/common/src/vault/services/fido2/fido2-client.service.ts create mode 100644 libs/common/src/vault/services/fido2/fido2-utils.ts create mode 100644 libs/common/src/vault/services/fido2/guid-utils.ts create mode 100644 libs/common/src/vault/services/fido2/noop-fido2-user-interface.service.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 22330901579..9a68f8bb9fb 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -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" } } diff --git a/apps/browser/src/auth/guards/fido2-auth.guard.ts b/apps/browser/src/auth/guards/fido2-auth.guard.ts new file mode 100644 index 00000000000..7ff0060663d --- /dev/null +++ b/apps/browser/src/auth/guards/fido2-auth.guard.ts @@ -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; +}; diff --git a/apps/browser/src/auth/popup/lock.component.html b/apps/browser/src/auth/popup/lock.component.html index e787e0106d1..989dc8a9537 100644 --- a/apps/browser/src/auth/popup/lock.component.html +++ b/apps/browser/src/auth/popup/lock.component.html @@ -11,81 +11,91 @@
-
-
-
-
- - -
-
- - -
-
- + +
+
+
+
+ + +
+
+ + +
+
+ +
+
- -
- -
-

- -

- - {{ biometricError }} -

- {{ "awaitDesktop" | i18n }} -

+

+ +

+ + {{ biometricError }} +

+ {{ "awaitDesktop" | i18n }} +

+ + +
diff --git a/apps/browser/src/auth/popup/lock.component.ts b/apps/browser/src/auth/popup/lock.component.ts index f5f8a29eb69..4a0c752a5d2 100644 --- a/apps/browser/src/auth/popup/lock.component.ts +++ b/apps/browser/src/auth/popup/lock.component.ts @@ -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() { diff --git a/apps/browser/src/autofill/background/context-menus.background.ts b/apps/browser/src/autofill/background/context-menus.background.ts index bc26353cbd9..add392f3293 100644 --- a/apps/browser/src/autofill/background/context-menus.background.ts +++ b/apps/browser/src/autofill/background/context-menus.background.ts @@ -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"); + }); } } ); diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 73bdc2cd16f..f95ca60bab1 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -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); } ); diff --git a/apps/browser/src/background/commands.background.ts b/apps/browser/src/background/commands.background.ts index 0cbf91c6666..77300272d26 100644 --- a/apps/browser/src/background/commands.background.ts +++ b/apps/browser/src/background/commands.background.ts @@ -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) => { diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index caac8e6bb81..4fcfa685270 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -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() || diff --git a/apps/browser/src/background/nativeMessaging.background.ts b/apps/browser/src/background/nativeMessaging.background.ts index 88fd81a3a70..f4eed16823a 100644 --- a/apps/browser/src/background/nativeMessaging.background.ts +++ b/apps/browser/src/background/nativeMessaging.background.ts @@ -383,7 +383,7 @@ export class NativeMessagingBackground { return; } - this.runtimeBackground.processMessage({ command: "unlocked" }, null, null); + this.runtimeBackground.processMessage({ command: "unlocked" }, null); } break; } diff --git a/apps/browser/src/background/runtime.background.ts b/apps/browser/src/background/runtime.background.ts index e921a736f71..53711c1dc11 100644 --- a/apps/browser/src/background/runtime.background.ts +++ b/apps/browser/src/background/runtime.background.ts @@ -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) + ); } } diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 41efa59632b..f0da7195f29 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -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" diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index ba8668984de..e87cdc0023b 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -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": [""] } ], diff --git a/apps/browser/src/platform/browser/browser-api.ts b/apps/browser/src/platform/browser/browser-api.ts index 5d0c2f1a519..e8070f0d341 100644 --- a/apps/browser/src/platform/browser/browser-api.ts +++ b/apps/browser/src/platform/browser/browser-api.ts @@ -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 | 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((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); diff --git a/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts b/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts index 001c546b9c6..120c4b8b58c 100644 --- a/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts +++ b/apps/browser/src/platform/decorators/session-sync-observable/session-syncer.ts @@ -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) { diff --git a/apps/browser/src/platform/popup/abstractions/browser-popout-window.service.ts b/apps/browser/src/platform/popup/abstractions/browser-popout-window.service.ts index 0ded45bea94..f48649c4f23 100644 --- a/apps/browser/src/platform/popup/abstractions/browser-popout-window.service.ts +++ b/apps/browser/src/platform/popup/abstractions/browser-popout-window.service.ts @@ -28,6 +28,15 @@ interface BrowserPopoutWindowService { } ): Promise; closePasswordRepromptPrompt(): Promise; + openFido2Popout( + senderWindow: chrome.tabs.Tab, + promptData: { + sessionId: string; + senderTabId: number; + fallbackSupported: boolean; + } + ): Promise; + closeFido2Popout(): Promise; } export { BrowserPopoutWindowService }; diff --git a/apps/browser/src/platform/popup/browser-popout-window.service.ts b/apps/browser/src/platform/popup/browser-popout-window.service.ts index f5ac2f3128e..f72f7bfb3e0 100644 --- a/apps/browser/src/platform/popup/browser-popout-window.service.ts +++ b/apps/browser/src/platform/popup/browser-popout-window.service.ts @@ -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 { + 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 { + await this.closeSingleActionPopout("fido2Popout"); + } + private async openSingleActionPopout( senderWindowId: number, popupWindowURL: string, - singleActionPopoutKey: string - ) { + singleActionPopoutKey: string, + options: chrome.windows.CreateData = {} + ): Promise { 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) { diff --git a/apps/browser/src/platform/popup/services/browser-router.service.ts b/apps/browser/src/platform/popup/services/browser-router.service.ts new file mode 100644 index 00000000000..dfc816f4ccc --- /dev/null +++ b/apps/browser/src/platform/popup/services/browser-router.service.ts @@ -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; + } +} diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 8ef51913f6d..10159a715f0 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -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", diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 93f824a7dd1..16ef77ed8f5 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -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) => { diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index 9dbd87cff57..dd27a419a34 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -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, diff --git a/apps/browser/src/popup/images/bwi-passkey.png b/apps/browser/src/popup/images/bwi-passkey.png new file mode 100644 index 0000000000000000000000000000000000000000..702be33446ef4adde39ab26e20b6e0e311b70594 GIT binary patch literal 1078 zcmV-61j+k}P)5UsHz5Ul0~8BP$h%K{1e1UM&%eS!=N#BbqY!N+2OZvdPi~?=GP`&M z5(vn9<7%ff8a&gsk$6($9;UnNbyam$^@w~>crqb2H#e)E=he}^j282}7_GOzzke=M z!kO64&Q1eAEwo_FFcOTWA?QSGcX#*X_4PG0&*&!_(nnv0WGmMDi-(7YW~b8`h$B7~ z9hf53e|~;mZ@1gM^f+j=f^jDxoS1rHVS$8e;)p^TQ}dm&525FWhlh>4IosRY5s0;P z&K~AO;(%OYQ55-)kB>L#1~eT6EJ;CNLqG9TM;qMV-!HkCw3Ighq_}Xv{>#gYlS$_0 z=9(mh=EJ0+%iLUIz#9KRTRr$xEDnISNvfxhtKxuMV(^ndyO%Nnu%Sy`r{Xd%mlzf{ z3pyWpC1mFLF7t|*GM;4NX;DODF@Lf1&oUV*x=!C^QW1?A->QdUIEmJh!-I{@mKYYi z&@PXBOB{eW_Cqw(AX7Ny&2rv=tE;QuD=RBYJXL64(T}gMuW$3_F*V^Q*%Wi;(a}*` z98koRGCw~bTE2D$p{*QJ)KphV#R$yn;)uf41?sP$R4R3?xE7;zV4_5Oc<1wgfCy`} zs*Yi(s2v;}IQf?oF+;9gE>jahf#Cy?UUtnE28B#Xu)=U4Mfe<#a_PHFfJ}|qWiy%y z5q3%$z;AfU`o{MNniJ2QyJnf3h@riji}t)UAuQgqd?X8nOq8+K0Hm8Jn=ZtKn4YAL zO+fUx1Jpz%rG*Giy(c-?+S+P>;E8BTQ_c1A<|V{XZPIKk>df(OW=*CLR;7kuQ6Z@l zJ$QP0svRF6_j8FsTX&)rDvQB!X37W~GXZ2bVCGSFC}}+(@F9;B!S~dt%oq&Zu-xbb zdwAgPtE^DLI4PZ(tP$<~aU^Me%$Z3kWV;8}TnCSi2aWxap>rqyNCnY89DG@(fT>9A ziwI@do6c~Lhym7D*2zQ+Xvn`HTG`mxa4LyyP~hU?;tg8Y5M*-4#&6R37V<}ZD9Pi6 zH@U?av*_)5Gzdf|Dyh@yi{$V>CUc&C%8EdauYSoe;T!phkpdxK3D{#QO4LS#7Ah$!fTYg&g3cd;1OuQ`T!N50DFEKW6G3__mtp4k zhWV6fUv&PfLP|J&XZnIWJsqbBi&R&Nk)j47HYJ!4;O21|W&}ubEmK^&H1bqf)e`pR zCnO!mZy13QjF^@g!7*--7M4DZ{QQ8gL78D` { 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 { + switch (popout.type) { + case "window": + return BrowserApi.removeWindow(popout.window.id); + case "tab": + return BrowserApi.removeTab(popout.tab.id); } } diff --git a/apps/browser/src/vault/background/abort-manager.ts b/apps/browser/src/vault/background/abort-manager.ts new file mode 100644 index 00000000000..8e61ca7a7b4 --- /dev/null +++ b/apps/browser/src/vault/background/abort-manager.ts @@ -0,0 +1,21 @@ +type Runner = (abortController: AbortController) => Promise; + +/** + * 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(); + + runWithAbortController(id: string, runner: Runner): Promise { + 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(); + } +} diff --git a/apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts b/apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts new file mode 100644 index 00000000000..80af1e05136 --- /dev/null +++ b/apps/browser/src/vault/fido2/browser-fido2-user-interface.service.ts @@ -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 { + 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 { + 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).pipe( + filter((msg) => msg.sessionId === this.sessionId) + ); + private connected$ = new BehaviorSubject(false); + private windowClosed$: Observable; + private destroy$ = new Subject(); + + 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 { + const data: BrowserFido2Message = { + type: "InformExcludedCredentialRequest", + sessionId: this.sessionId, + existingCipherIds, + fallbackSupported: this.fallbackSupported, + }; + + await this.send(data); + await this.receive("AbortResponse"); + } + + async ensureUnlockedVault(): Promise { + await this.connect(); + } + + async informCredentialNotFound(): Promise { + 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 { + if (!this.connected$.value) { + await this.connect(); + } + BrowserFido2UserInterfaceSession.sendMessage(msg); + } + + private async receive( + type: T + ): Promise { + 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 { + 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; + } +} diff --git a/apps/browser/src/vault/fido2/content/content-script.ts b/apps/browser/src/vault/fido2/content/content-script.ts new file mode 100644 index 00000000000..bf147c4b58f --- /dev/null +++ b/apps/browser/src/vault/fido2/content/content-script.ts @@ -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; + } + + return undefined; + }; +} + +checkFido2FeatureEnabled(); diff --git a/apps/browser/src/vault/fido2/content/messaging/message.ts b/apps/browser/src/vault/fido2/content/messaging/message.ts new file mode 100644 index 00000000000..01a19a1f8a4 --- /dev/null +++ b/apps/browser/src/vault/fido2/content/messaging/message.ts @@ -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; diff --git a/apps/browser/src/vault/fido2/content/messaging/messenger.spec.ts b/apps/browser/src/vault/fido2/content/messaging/messenger.spec.ts new file mode 100644 index 00000000000..505682d997d --- /dev/null +++ b/apps/browser/src/vault/fido2/content/messaging/messenger.spec.ts @@ -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(); + + 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; + + 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 { + port1 = new MockMessagePort(); + port2 = new MockMessagePort(); + + constructor() { + this.port1.remotePort = this.port2; + this.port2.remotePort = this.port1; + } +} + +class MockMessagePort { + onmessage: ((ev: MessageEvent) => any) | null; + remotePort: MockMessagePort; + + postMessage(message: T, port?: MessagePort) { + this.remotePort.onmessage( + new MessageEvent("message", { data: message, ports: port ? [port] : [] }) + ); + } + + close() { + // Do nothing + } +} diff --git a/apps/browser/src/vault/fido2/content/messaging/messenger.ts b/apps/browser/src/vault/fido2/content/messaging/messenger.ts new file mode 100644 index 00000000000..aeb835e2d5f --- /dev/null +++ b/apps/browser/src/vault/fido2/content/messaging/messenger.ts @@ -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) => void) => void; + postMessage: PostMessageFunction; +}; + +export type Metadata = { SENDER: typeof SENDER }; +export type MessageWithMetadata = Message & Metadata; +type Handler = ( + message: MessageWithMetadata, + abortController?: AbortController +) => Promise; + +/** + * 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) => { + if (event.origin !== windowOrigin) { + return; + } + + listener(event as MessageEvent); + }), + }); + } + + /** + * 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) => { + 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 { + const requestChannel = new MessageChannel(); + const { port1: localPort, port2: remotePort } = requestChannel; + + try { + const promise = new Promise((resolve) => { + localPort.onmessage = (event: MessageEvent) => 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(); + } + } +} diff --git a/apps/browser/src/vault/fido2/content/page-script.ts b/apps/browser/src/vault/fido2/content/page-script.ts new file mode 100644 index 00000000000..1f5d98289ba --- /dev/null +++ b/apps/browser/src/vault/fido2/content/page-script.ts @@ -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 => { + 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 => { + 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; + } +}; diff --git a/apps/browser/src/vault/fido2/webauthn-utils.ts b/apps/browser/src/vault/fido2/webauthn-utils.ts new file mode 100644 index 00000000000..2422736077f --- /dev/null +++ b/apps/browser/src/vault/fido2/webauthn-utils.ts @@ -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; + } +} diff --git a/apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.html b/apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.html new file mode 100644 index 00000000000..42e8a6b6298 --- /dev/null +++ b/apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.html @@ -0,0 +1,27 @@ +
+
+ +
+
diff --git a/apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.ts b/apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.ts new file mode 100644 index 00000000000..21ff136bf42 --- /dev/null +++ b/apps/browser/src/vault/popup/components/fido2/fido2-cipher-row.component.ts @@ -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(); + @Input() cipher: CipherView; + @Input() last: boolean; + @Input() title: string; + @Input() isSearching: boolean; + @Input() isSelected: boolean; + + selectCipher(c: CipherView) { + this.onSelected.emit(c); + } +} diff --git a/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.html b/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.html new file mode 100644 index 00000000000..3e71675aa2c --- /dev/null +++ b/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.html @@ -0,0 +1,5 @@ + diff --git a/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.ts b/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.ts new file mode 100644 index 00000000000..712f728c320 --- /dev/null +++ b/apps/browser/src/vault/popup/components/fido2/fido2-use-browser-link.component.ts @@ -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; + } +} diff --git a/apps/browser/src/vault/popup/components/fido2/fido2.component.html b/apps/browser/src/vault/popup/components/fido2/fido2.component.html new file mode 100644 index 00000000000..0f298b67fb6 --- /dev/null +++ b/apps/browser/src/vault/popup/components/fido2/fido2.component.html @@ -0,0 +1,139 @@ + +
+
+
+ + + + + + +
+ + +
+ +
+
+
+ + + +
+

+ {{ subtitleText | i18n }} +

+ + +
+
+ +
+
+ +
+ +
+
+ + +
+ +
+
+
+
+ +
+

{{ "passkeyAlreadyExists" | i18n }}

+
+
+ +
+
+ +
+
+ +
+

{{ "noPasskeysFoundForThisApplication" | i18n }}

+
+ +
+
+ +
+
diff --git a/apps/browser/src/vault/popup/components/fido2/fido2.component.ts b/apps/browser/src/vault/popup/components/fido2/fido2.component.ts new file mode 100644 index 00000000000..ed0ddbd1443 --- /dev/null +++ b/apps/browser/src/vault/popup/components/fido2/fido2.component.ts @@ -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(); + 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; + 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(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]) + .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 { + 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, + }); + } +} diff --git a/apps/browser/src/vault/popup/components/vault/add-edit.component.html b/apps/browser/src/vault/popup/components/vault/add-edit.component.html index dda71cb0d6e..b2a42776e18 100644 --- a/apps/browser/src/vault/popup/components/vault/add-edit.component.html +++ b/apps/browser/src/vault/popup/components/vault/add-edit.component.html @@ -129,6 +129,18 @@ + + +
+
+
+ {{ "typePasskey" | i18n }} + {{ "dateCreated" | i18n }} + {{ cipher.login.fido2Credentials[0].creationDate | date : "short" }} +
+
+
+
{ + 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 { + if (userVerification && !(await this.passwordRepromptService.showPasswordPrompt())) { + BrowserFido2UserInterfaceSession.abortPopout(sessionId); + return false; + } + + return true; + } + repromptChanged() { super.repromptChanged(); diff --git a/apps/browser/src/vault/popup/components/vault/vault-filter.component.html b/apps/browser/src/vault/popup/components/vault/vault-filter.component.html index 8e459a26006..564cf19f2c4 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-filter.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault-filter.component.html @@ -70,7 +70,9 @@
{{ "typeLogin" | i18n }}
- {{ typeCounts.get(cipherType.Login) || 0 }} + + {{ typeCounts.get(cipherType.Login) || 0 }} + + +
+
+ +
+ +
+
+
+
diff --git a/apps/web/src/app/vault/individual-vault/add-edit.component.ts b/apps/web/src/app/vault/individual-vault/add-edit.component.ts index 74e7a9e6e30..cf998c5c8f6 100644 --- a/apps/web/src/app/vault/individual-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/individual-vault/add-edit.component.ts @@ -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; } diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 3b8d60d263b..aae45d27e25 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -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; } diff --git a/apps/web/src/app/vault/org-vault/add-edit.component.ts b/apps/web/src/app/vault/org-vault/add-edit.component.ts index c8a37c087ad..93be56f1a0b 100644 --- a/apps/web/src/app/vault/org-vault/add-edit.component.ts +++ b/apps/web/src/app/vault/org-vault/add-edit.component.ts @@ -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 ); } diff --git a/apps/web/src/app/vault/org-vault/vault.component.ts b/apps/web/src/app/vault/org-vault/vault.component.ts index dcdd73da3c4..22aa277fc22 100644 --- a/apps/web/src/app/vault/org-vault/vault.component.ts +++ b/apps/web/src/app/vault/org-vault/vault.component.ts @@ -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 ); diff --git a/apps/web/src/images/bwi-passkey.png b/apps/web/src/images/bwi-passkey.png new file mode 100644 index 0000000000000000000000000000000000000000..702be33446ef4adde39ab26e20b6e0e311b70594 GIT binary patch literal 1078 zcmV-61j+k}P)5UsHz5Ul0~8BP$h%K{1e1UM&%eS!=N#BbqY!N+2OZvdPi~?=GP`&M z5(vn9<7%ff8a&gsk$6($9;UnNbyam$^@w~>crqb2H#e)E=he}^j282}7_GOzzke=M z!kO64&Q1eAEwo_FFcOTWA?QSGcX#*X_4PG0&*&!_(nnv0WGmMDi-(7YW~b8`h$B7~ z9hf53e|~;mZ@1gM^f+j=f^jDxoS1rHVS$8e;)p^TQ}dm&525FWhlh>4IosRY5s0;P z&K~AO;(%OYQ55-)kB>L#1~eT6EJ;CNLqG9TM;qMV-!HkCw3Ighq_}Xv{>#gYlS$_0 z=9(mh=EJ0+%iLUIz#9KRTRr$xEDnISNvfxhtKxuMV(^ndyO%Nnu%Sy`r{Xd%mlzf{ z3pyWpC1mFLF7t|*GM;4NX;DODF@Lf1&oUV*x=!C^QW1?A->QdUIEmJh!-I{@mKYYi z&@PXBOB{eW_Cqw(AX7Ny&2rv=tE;QuD=RBYJXL64(T}gMuW$3_F*V^Q*%Wi;(a}*` z98koRGCw~bTE2D$p{*QJ)KphV#R$yn;)uf41?sP$R4R3?xE7;zV4_5Oc<1wgfCy`} zs*Yi(s2v;}IQf?oF+;9gE>jahf#Cy?UUtnE28B#Xu)=U4Mfe<#a_PHFfJ}|qWiy%y z5q3%$z;AfU`o{MNniJ2QyJnf3h@riji}t)UAuQgqd?X8nOq8+K0Hm8Jn=ZtKn4YAL zO+fUx1Jpz%rG*Giy(c-?+S+P>;E8BTQ_c1A<|V{XZPIKk>df(OW=*CLR;7kuQ6Z@l zJ$QP0svRF6_j8FsTX&)rDvQB!X37W~GXZ2bVCGSFC}}+(@F9;B!S~dt%oq&Zu-xbb zdwAgPtE^DLI4PZ(tP$<~aU^Me%$Z3kWV;8}TnCSi2aWxap>rqyNCnY89DG@(fT>9A ziwI@do6c~Lhym7D*2zQ+Xvn`HTG`mxa4LyyP~hU?;tg8Y5M*-4#&6R37V<}ZD9Pi6 zH@U?av*_)5Gzdf|Dyh@yi{$V>CUc&C%8EdauYSoe;T!phkpdxK3D{#QO4LS#7Ah$!fTYg&g3cd;1OuQ`T!N50DFEKW6G3__mtp4k zhWV6fUv&PfLP|J&XZnIWJsqbBi&R&Nk)j47HYJ!4;O21|W&}ubEmK^&H1bqf)e`pR zCnO!mZy13QjF^@g!7*--7M4DZ{QQ8gL78D`(); + get loggedEmail() { return this.formGroup.value.email; } @@ -83,14 +86,17 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit } async ngOnInit() { - this.route?.queryParams.subscribe((params) => { - if (params != null) { - const queryParamsEmail = params["email"]; - if (queryParamsEmail != null && queryParamsEmail.indexOf("@") > -1) { - this.formGroup.get("email").setValue(queryParamsEmail); - this.loginService.setEmail(queryParamsEmail); - this.paramEmailSet = true; - } + this.route?.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => { + if (!params) { + return; + } + + const queryParamsEmail = params.email; + + if (queryParamsEmail != null && queryParamsEmail.indexOf("@") > -1) { + this.formGroup.get("email").setValue(queryParamsEmail); + this.loginService.setEmail(queryParamsEmail); + this.paramEmailSet = true; } }); let email = this.loginService.getEmail(); @@ -109,6 +115,11 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit this.formGroup.get("rememberEmail")?.setValue(rememberEmail); } + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + async submit(showToast = true) { const data = this.formGroup.value; diff --git a/libs/angular/src/scss/bwicons/fonts/bwi-font.svg b/libs/angular/src/scss/bwicons/fonts/bwi-font.svg index 6ea08093176..e815389126c 100644 --- a/libs/angular/src/scss/bwicons/fonts/bwi-font.svg +++ b/libs/angular/src/scss/bwicons/fonts/bwi-font.svg @@ -53,7 +53,7 @@ - + @@ -71,7 +71,7 @@ - + @@ -131,9 +131,9 @@ - + - + @@ -185,6 +185,8 @@ + + diff --git a/libs/angular/src/scss/bwicons/fonts/bwi-font.ttf b/libs/angular/src/scss/bwicons/fonts/bwi-font.ttf index f3d2459393d5d18d8cd136becbdf36a9636c8948..f9b63283e0465ea0285ac022a2aa1540746d3d60 100644 GIT binary patch delta 1180 zcmZ8hOKcle6usAvk<579CQV%D*GNDjS{gpZ#27bOG}KKa z2o+#2VG(e#DoYg$3lNGBu<;_5K!AkACXuMhrptEA0#%^y8izY}BZa;<@7;Iq=bfi> zrFp#P*uU$js^xzGFaQ7-SM%3OWIcqp$abw>e*e33du$V)(}G8a8UrV;}A|*YJmlntN}#xjxw&sfm-XHvcS) zuhuT@FE@*Oh_&m5SDHWXpIabu%$jbDMPnLmW~!Yijf!JbK%W#Lw}z_HF0l*Z*l5boql9_Mq**p& z&UPikGNL4=rtmv47!>`XkpF8VD+qI1(lC%7blW~iPK&$ zBF|3^4keu~r_;r_gWLrSGrkjpt0&dz(6fWw_SMCuQfX=N>gw;WPi3;1kru~`^FwoQ zA-*wJ9$Jw2Vuw%+;W4s~7eo<^0D~q_f%1>*Pqsa*q4)e+}%%AogRsy=6uXE72Gc z8D2eQ@?onvVTJK~1yO1<^9t)mmgz?dwL4{}D7_%up~8pVEZ^J9v)utf2vmD{3-H@d z?DuU%o|UH^2N_a0sg(^wOV0WQb3xALa@iL2Jtxp0y~+a$m+(Rp&UY zaGb8HAIH<_xDBiwOD-QfY+XLg`dmUF(E8wr^2o|hs7Ic5*v{$6d2P-w2>#h*QZKm9 zp1PCEW^-Qd#VoFzI_qkEbtg`v)ldgmx9rL0Y!we@sA_a&g%mS9+(9NCw_XuX@45qc z!5;YbhWXx`+{7brIjGA3_dmb+?~$!nLTKVn{dNdHLaduI&fB27UXk(d9_V(!;UfqA x2I^a3{KE-Up4pvI@MY|?efn2bslHaN(nUyX$Lp%bi delta 330 zcmX?eiRI5}mI=!B?-&>uWf&M3V$u_f3xKo$kY53$Inr|~(|#27Ndfr{42-gOGEx&$ z6v|FsV_=kz0m_?Y00mg~u<`==Q-FMxjNFonNg-mtf&3i|3`{d}@{<#%%nFoZU{neK zn&Fq5SW&>RlUaa)QF#GSy+U4MZmRMGF_V8lfdfDdG6ng?B@E0!DMr;h6L(BzoHyBr zQJQh<<_yMlHj}H8BRA*UajQ;FI}@?_QnF3@WHGz&&8epu87K3d4&S`@Oo;#XPM)8Ke9euzcVqgT~Wz2jGLLj3Un5G+QG74`OR%f(i F0sx$8Tn7LE diff --git a/libs/angular/src/scss/bwicons/fonts/bwi-font.woff b/libs/angular/src/scss/bwicons/fonts/bwi-font.woff index c17abc55a63d925594ee5ed6b616f6df32364695..1e57b1aab32302c3438d584d5f9056158fb1c5b9 100644 GIT binary patch delta 1251 zcmZ8h-A^1<6hCJ_W`Hd_yF0Tp8kXJJ8Q2d#c2n5d*%k`hr7V!B5UWM8u272bQ0$fx zi$+}%U-SW8BxalXvQcA*Q4__47)|t{X-tZ-(SM-E7cr)3G<}m@&mADa&YjsS}UmMTo0RaHx*TH4k#{etGBPg$TMN5Cr&s;6i;!Rp7saF*!fQ*vED#B(w~=qb@tspxSX^EqZWO>)Ww`N_(s$|d zEXB3|L3=*Q(0=!~PZnoZihv!_eo9s?d~5f-G_yDd7(Gvk0S3LCsueF^U8cjYtpEE# zYjyX2eXm}pa7x;R-FudY_1_hTyuI}`e$l@%b$fE_n}O!`jSBA*?(8Ekyfl80uO9^xUN5334t8`V zoi3--#kqsRISg~YV;%D+^r6tB9m3j`@rhz_V*JYdt>*@_xome$5ap51`~}44^QF!) zMV#9gl!G{=EuGny+m}AGr1{LYCNIDEE@=*6cZ$?}1Mzq=5s&pTZ9C^^aC2qky-jMn z(Cm&pf(uNJuvf-O63ldAYdDU0K6iQsF`~9m!eH!hTpe|B))v+_d8MW0WH?M|u2T16whOt!TBk#OYxz5+xzSn@XEv zm^CM?ael8PE0(gT@owa~R-`4XQ+8UV6NLAr@O~pNHaClWV?dGu=*hXw?Jh?uQB6*;H~Qk@riIe>j;;&ALWU-YMjA z1+VZ#4yR9!x@sTYiP2T7>TSGR_2de+RgYlkdSrT~>khr?JKM&YxwiJXJ2!KJ4eUX?KHk_wxHaubb4=`i?X#fBK delta 401 zcmbPnndQV87O`@FH#Y`G1_lO3H4g@E5Uu)$fpL1HCZpIy9rgNm>50V!K*1wG84f5G zNYANE1B#ttV3fTB!as`oq%u+yQy3WKR{+(Rfv`f^$!i%vL7>u2-~a!C%7L0*GQ4~T<}v<%EY2dfb@LX+ zbvBcaB}Z=FZO5%TdDEGQ&2lL=>64S}!Z&X`&B!=8>U8*K&a)x@+ZW0+1~bc42D=!> z^V@u7;08Jt2p+IImcr=i0?Lf$jLW8bC^ObEGE6_N%;?UP|F1wj uoYOf}8P9LOugVz2DBjSwqwgJ1J1D4^G4nA9fsA5c0tK}2_IPzhOC|v5?{A3! diff --git a/libs/angular/src/scss/bwicons/fonts/bwi-font.woff2 b/libs/angular/src/scss/bwicons/fonts/bwi-font.woff2 index 1685db245468fc1b9623fe389baac5173320a9f8..88036b7b3e8eda7d55afaceea4beb95b02821c88 100644 GIT binary patch literal 33420 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 (c as any).checked).map((c) => c.id); } - // Clear current Cipher Id to trigger "Add" cipher flow + // Clear current Cipher Id if exists to trigger "Add" cipher flow if (this.cloneMode) { this.cipher.id = null; } diff --git a/libs/angular/src/vault/components/view.component.ts b/libs/angular/src/vault/components/view.component.ts index 781034aa9f3..90032617b46 100644 --- a/libs/angular/src/vault/components/view.component.ts +++ b/libs/angular/src/vault/components/view.component.ts @@ -29,10 +29,10 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; +import { Launchable } from "@bitwarden/common/vault/interfaces/launchable"; import { AttachmentView } from "@bitwarden/common/vault/models/view/attachment.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; -import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { DialogService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; @@ -62,6 +62,7 @@ export class ViewComponent implements OnDestroy, OnInit { fieldType = FieldType; checkPasswordPromise: Promise; folder: FolderView; + cipherType = CipherType; private totpInterval: any; private previousCipherId: string; @@ -156,6 +157,18 @@ export class ViewComponent implements OnDestroy, OnInit { } async clone() { + if (this.cipher.login?.hasFido2Credentials) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "passkeyNotCopied" }, + content: { key: "passkeyNotCopiedAlert" }, + type: "info", + }); + + if (!confirmed) { + return false; + } + } + if (await this.promptPassword()) { this.onCloneCipher.emit(this.cipher); return true; @@ -295,7 +308,7 @@ export class ViewComponent implements OnDestroy, OnInit { } } - launch(uri: LoginUriView, cipherId?: string) { + launch(uri: Launchable, cipherId?: string) { if (!uri.canLaunch) { return; } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index cc0873351b8..398d63fe964 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -1,6 +1,7 @@ export enum FeatureFlag { DisplayEuEnvironmentFlag = "display-eu-environment", DisplayLowKdfIterationWarningFlag = "display-kdf-iteration-warning", + Fido2VaultCredentials = "fido2-vault-credentials", TrustedDeviceEncryption = "trusted-device-encryption", PasswordlessLogin = "passwordless-login", AutofillV2 = "autofill-v2", diff --git a/libs/common/src/models/api/login.api.ts b/libs/common/src/models/api/login.api.ts index 82e28dd0a35..934d2e99b5b 100644 --- a/libs/common/src/models/api/login.api.ts +++ b/libs/common/src/models/api/login.api.ts @@ -1,3 +1,6 @@ +import { JsonObject } from "type-fest"; + +import { Fido2CredentialApi } from "../../vault/api/fido2-credential.api"; import { BaseResponse } from "../response/base.response"; import { LoginUriApi } from "./login-uri.api"; @@ -9,6 +12,7 @@ export class LoginApi extends BaseResponse { passwordRevisionDate: string; totp: string; autofillOnPageLoad: boolean; + fido2Credentials?: Fido2CredentialApi[]; constructor(data: any = null) { super(data); @@ -25,5 +29,12 @@ export class LoginApi extends BaseResponse { if (uris != null) { this.uris = uris.map((u: any) => new LoginUriApi(u)); } + + const fido2Credentials = this.getResponseProperty("Fido2Credentials"); + if (fido2Credentials != null) { + this.fido2Credentials = fido2Credentials.map( + (key: JsonObject) => new Fido2CredentialApi(key) + ); + } } } diff --git a/libs/common/src/models/export/fido2-credential.export.ts b/libs/common/src/models/export/fido2-credential.export.ts new file mode 100644 index 00000000000..258699c8daf --- /dev/null +++ b/libs/common/src/models/export/fido2-credential.export.ts @@ -0,0 +1,125 @@ +import { EncString } from "../../platform/models/domain/enc-string"; +import { Fido2Credential } from "../../vault/models/domain/fido2-credential"; +import { Fido2CredentialView } from "../../vault/models/view/fido2-credential.view"; + +/** + * Represents format of Fido2 Credentials in JSON exports. + */ +export class Fido2CredentialExport { + /** + * Generates a template for Fido2CredentialExport + * @returns Instance of Fido2CredentialExport with predefined values. + */ + static template(): Fido2CredentialExport { + const req = new Fido2CredentialExport(); + req.credentialId = "keyId"; + req.keyType = "keyType"; + req.keyAlgorithm = "keyAlgorithm"; + req.keyCurve = "keyCurve"; + req.keyValue = "keyValue"; + req.rpId = "rpId"; + req.userHandle = "userHandle"; + req.counter = "counter"; + req.rpName = "rpName"; + req.userDisplayName = "userDisplayName"; + req.discoverable = "false"; + req.creationDate = null; + return req; + } + + /** + * Converts a Fido2CredentialExport object to its view representation. + * @param req - The Fido2CredentialExport object to be converted. + * @param view - (Optional) The Fido2CredentialView object to popualte with Fido2CredentialExport data + * @returns Fido2CredentialView - The populated view, or a new instance if none was provided. + */ + static toView(req: Fido2CredentialExport, view = new Fido2CredentialView()) { + view.credentialId = req.credentialId; + view.keyType = req.keyType as "public-key"; + view.keyAlgorithm = req.keyAlgorithm as "ECDSA"; + view.keyCurve = req.keyCurve as "P-256"; + view.keyValue = req.keyValue; + view.rpId = req.rpId; + view.userHandle = req.userHandle; + view.counter = parseInt(req.counter); + view.rpName = req.rpName; + view.userDisplayName = req.userDisplayName; + view.discoverable = req.discoverable === "true"; + view.creationDate = new Date(req.creationDate); + return view; + } + + /** + * Converts a Fido2CredentialExport object to its domain representation. + * @param req - The Fido2CredentialExport object to be converted. + * @param domain - (Optional) The Fido2Credential object to popualte with Fido2CredentialExport data + * @returns Fido2Credential - The populated domain, or a new instance if none was provided. + */ + static toDomain(req: Fido2CredentialExport, domain = new Fido2Credential()) { + domain.credentialId = req.credentialId != null ? new EncString(req.credentialId) : null; + domain.keyType = req.keyType != null ? new EncString(req.keyType) : null; + domain.keyAlgorithm = req.keyAlgorithm != null ? new EncString(req.keyAlgorithm) : null; + domain.keyCurve = req.keyCurve != null ? new EncString(req.keyCurve) : null; + domain.keyValue = req.keyValue != null ? new EncString(req.keyValue) : null; + domain.rpId = req.rpId != null ? new EncString(req.rpId) : null; + domain.userHandle = req.userHandle != null ? new EncString(req.userHandle) : null; + domain.counter = req.counter != null ? new EncString(req.counter) : null; + domain.rpName = req.rpName != null ? new EncString(req.rpName) : null; + domain.userDisplayName = + req.userDisplayName != null ? new EncString(req.userDisplayName) : null; + domain.discoverable = req.discoverable != null ? new EncString(req.discoverable) : null; + domain.creationDate = req.creationDate; + return domain; + } + + credentialId: string; + keyType: string; + keyAlgorithm: string; + keyCurve: string; + keyValue: string; + rpId: string; + userHandle: string; + counter: string; + rpName: string; + userDisplayName: string; + discoverable: string; + creationDate: Date; + + /** + * Constructs a new Fid2CredentialExport instance. + * + * @param o - The credential storing the data being exported. When not provided, an empty export is created instead. + */ + constructor(o?: Fido2CredentialView | Fido2Credential) { + if (o == null) { + return; + } + + if (o instanceof Fido2CredentialView) { + this.credentialId = o.credentialId; + this.keyType = o.keyType; + this.keyAlgorithm = o.keyAlgorithm; + this.keyCurve = o.keyCurve; + this.keyValue = o.keyValue; + this.rpId = o.rpId; + this.userHandle = o.userHandle; + this.counter = String(o.counter); + this.rpName = o.rpName; + this.userDisplayName = o.userDisplayName; + this.discoverable = String(o.discoverable); + } else { + this.credentialId = o.credentialId?.encryptedString; + this.keyType = o.keyType?.encryptedString; + this.keyAlgorithm = o.keyAlgorithm?.encryptedString; + this.keyCurve = o.keyCurve?.encryptedString; + this.keyValue = o.keyValue?.encryptedString; + this.rpId = o.rpId?.encryptedString; + this.userHandle = o.userHandle?.encryptedString; + this.counter = o.counter?.encryptedString; + this.rpName = o.rpName?.encryptedString; + this.userDisplayName = o.userDisplayName?.encryptedString; + this.discoverable = o.discoverable?.encryptedString; + } + this.creationDate = o.creationDate; + } +} diff --git a/libs/common/src/models/export/index.ts b/libs/common/src/models/export/index.ts index b92c68d8143..0b338572207 100644 --- a/libs/common/src/models/export/index.ts +++ b/libs/common/src/models/export/index.ts @@ -9,3 +9,4 @@ export { FolderExport } from "./folder.export"; export { IdentityExport } from "./identity.export"; export { LoginUriExport } from "./login-uri.export"; export { SecureNoteExport } from "./secure-note.export"; +export { Fido2CredentialExport } from "./fido2-credential.export"; diff --git a/libs/common/src/models/export/login.export.ts b/libs/common/src/models/export/login.export.ts index 7a22b12537f..a5d9348c2ca 100644 --- a/libs/common/src/models/export/login.export.ts +++ b/libs/common/src/models/export/login.export.ts @@ -2,6 +2,7 @@ import { EncString } from "../../platform/models/domain/enc-string"; import { Login as LoginDomain } from "../../vault/models/domain/login"; import { LoginView } from "../../vault/models/view/login.view"; +import { Fido2CredentialExport } from "./fido2-credential.export"; import { LoginUriExport } from "./login-uri.export"; export class LoginExport { @@ -11,6 +12,7 @@ export class LoginExport { req.username = "jdoe"; req.password = "myp@ssword123"; req.totp = "JBSWY3DPEHPK3PXP"; + req.fido2Credentials = [Fido2CredentialExport.template()]; return req; } @@ -21,6 +23,9 @@ export class LoginExport { view.username = req.username; view.password = req.password; view.totp = req.totp; + if (req.fido2Credentials != null) { + view.fido2Credentials = req.fido2Credentials.map((key) => Fido2CredentialExport.toView(key)); + } return view; } @@ -31,6 +36,8 @@ export class LoginExport { domain.username = req.username != null ? new EncString(req.username) : null; domain.password = req.password != null ? new EncString(req.password) : null; domain.totp = req.totp != null ? new EncString(req.totp) : null; + // Fido2credentials are currently not supported for exports. + return domain; } @@ -38,6 +45,7 @@ export class LoginExport { username: string; password: string; totp: string; + fido2Credentials: Fido2CredentialExport[] = []; constructor(o?: LoginView | LoginDomain) { if (o == null) { @@ -52,6 +60,10 @@ export class LoginExport { } } + if (o.fido2Credentials != null) { + this.fido2Credentials = o.fido2Credentials.map((key) => new Fido2CredentialExport(key)); + } + if (o instanceof LoginView) { this.username = o.username; this.password = o.password; diff --git a/libs/common/src/vault/abstractions/fido2/fido2-authenticator.service.abstraction.ts b/libs/common/src/vault/abstractions/fido2/fido2-authenticator.service.abstraction.ts new file mode 100644 index 00000000000..5a406aeb14c --- /dev/null +++ b/libs/common/src/vault/abstractions/fido2/fido2-authenticator.service.abstraction.ts @@ -0,0 +1,143 @@ +/** + * This class represents an abstraction of the WebAuthn Authenticator model as described by W3C: + * https://www.w3.org/TR/webauthn-3/#sctn-authenticator-model + * + * The authenticator provides key management and cryptographic signatures. + */ +export abstract class Fido2AuthenticatorService { + /** + * Create and save a new credential as described in: + * https://www.w3.org/TR/webauthn-3/#sctn-op-make-cred + * + * @param params Parameters for creating a new credential + * @param abortController An AbortController that can be used to abort the operation. + * @returns A promise that resolves with the new credential and an attestation signature. + **/ + makeCredential: ( + params: Fido2AuthenticatorMakeCredentialsParams, + tab: chrome.tabs.Tab, + abortController?: AbortController + ) => Promise; + + /** + * Generate an assertion using an existing credential as describe in: + * https://www.w3.org/TR/webauthn-3/#sctn-op-get-assertion + * + * @param params Parameters for generating an assertion + * @param abortController An AbortController that can be used to abort the operation. + * @returns A promise that resolves with the asserted credential and an assertion signature. + */ + getAssertion: ( + params: Fido2AuthenticatorGetAssertionParams, + tab: chrome.tabs.Tab, + abortController?: AbortController + ) => Promise; +} + +export enum Fido2AlgorithmIdentifier { + ES256 = -7, + RS256 = -257, +} + +export enum Fido2AutenticatorErrorCode { + Unknown = "UnknownError", + NotSupported = "NotSupportedError", + InvalidState = "InvalidStateError", + NotAllowed = "NotAllowedError", + Constraint = "ConstraintError", +} + +export class Fido2AutenticatorError extends Error { + constructor(readonly errorCode: Fido2AutenticatorErrorCode) { + super(errorCode); + } +} + +export interface PublicKeyCredentialDescriptor { + id: BufferSource; + transports?: ("ble" | "hybrid" | "internal" | "nfc" | "usb")[]; + type: "public-key"; +} + +/** + * Parameters for {@link Fido2AuthenticatorService.makeCredential} + * + * This interface represents the input parameters described in + * https://www.w3.org/TR/webauthn-3/#sctn-op-make-cred + */ +export interface Fido2AuthenticatorMakeCredentialsParams { + /** The hash of the serialized client data, provided by the client. */ + hash: BufferSource; + /** The Relying Party's PublicKeyCredentialRpEntity. */ + rpEntity: { + name: string; + id?: string; + }; + /** The user account’s PublicKeyCredentialUserEntity, containing the user handle given by the Relying Party. */ + userEntity: { + id: BufferSource; + name?: string; + displayName?: string; + icon?: string; + }; + /** A sequence of pairs of PublicKeyCredentialType and public key algorithms (COSEAlgorithmIdentifier) requested by the Relying Party. This sequence is ordered from most preferred to least preferred. The authenticator makes a best-effort to create the most preferred credential that it can. */ + credTypesAndPubKeyAlgs: { + alg: number; + type: "public-key"; // not used + }[]; + /** An OPTIONAL list of PublicKeyCredentialDescriptor objects provided by the Relying Party with the intention that, if any of these are known to the authenticator, it SHOULD NOT create a new credential. excludeCredentialDescriptorList contains a list of known credentials. */ + excludeCredentialDescriptorList?: PublicKeyCredentialDescriptor[]; + /** A map from extension identifiers to their authenticator extension inputs, created by the client based on the extensions requested by the Relying Party, if any. */ + extensions?: { + appid?: string; + appidExclude?: string; + credProps?: boolean; + uvm?: boolean; + }; + /** A Boolean value that indicates that individually-identifying attestation MAY be returned by the authenticator. */ + enterpriseAttestationPossible?: boolean; // Ignored by bitwarden at the moment + /** The effective resident key requirement for credential creation, a Boolean value determined by the client. Resident is synonymous with discoverable. */ + requireResidentKey: boolean; + requireUserVerification: boolean; + /** Forwarded to user interface */ + fallbackSupported: boolean; + /** The constant Boolean value true. It is included here as a pseudo-parameter to simplify applying this abstract authenticator model to implementations that may wish to make a test of user presence optional although WebAuthn does not. */ + // requireUserPresence: true; // Always required +} + +export interface Fido2AuthenticatorMakeCredentialResult { + credentialId: BufferSource; + attestationObject: BufferSource; + authData: BufferSource; + publicKeyAlgorithm: number; +} + +/** + * Parameters for {@link Fido2AuthenticatorService.getAssertion} + + * This interface represents the input parameters described in + * https://www.w3.org/TR/webauthn-3/#sctn-op-get-assertion + */ +export interface Fido2AuthenticatorGetAssertionParams { + /** The caller’s RP ID, as determined by the user agent and the client. */ + rpId: string; + /** The hash of the serialized client data, provided by the client. */ + hash: BufferSource; + allowCredentialDescriptorList: PublicKeyCredentialDescriptor[]; + /** The effective user verification requirement for assertion, a Boolean value provided by the client. */ + requireUserVerification: boolean; + /** The constant Boolean value true. It is included here as a pseudo-parameter to simplify applying this abstract authenticator model to implementations that may wish to make a test of user presence optional although WebAuthn does not. */ + // requireUserPresence: boolean; // Always required + extensions: unknown; + /** Forwarded to user interface */ + fallbackSupported: boolean; +} + +export interface Fido2AuthenticatorGetAssertionResult { + selectedCredential: { + id: Uint8Array; + userHandle?: Uint8Array; + }; + authenticatorData: Uint8Array; + signature: Uint8Array; +} diff --git a/libs/common/src/vault/abstractions/fido2/fido2-client.service.abstraction.ts b/libs/common/src/vault/abstractions/fido2/fido2-client.service.abstraction.ts new file mode 100644 index 00000000000..fca73c8d99e --- /dev/null +++ b/libs/common/src/vault/abstractions/fido2/fido2-client.service.abstraction.ts @@ -0,0 +1,174 @@ +export const UserRequestedFallbackAbortReason = "UserRequestedFallback"; + +export type UserVerification = "discouraged" | "preferred" | "required"; + +/** + * This class represents an abstraction of the WebAuthn Client as described by W3C: + * https://www.w3.org/TR/webauthn-3/#webauthn-client + * + * The WebAuthn Client is an intermediary entity typically implemented in the user agent + * (in whole, or in part). Conceptually, it underlies the Web Authentication API and embodies + * the implementation of the Web Authentication API's operations. + * + * It is responsible for both marshalling the inputs for the underlying authenticator operations, + * and for returning the results of the latter operations to the Web Authentication API's callers. + */ +export abstract class Fido2ClientService { + /** + * Allows WebAuthn Relying Party scripts to request the creation of a new public key credential source. + * For more information please see: https://www.w3.org/TR/webauthn-3/#sctn-createCredential + * + * @param params The parameters for the credential creation operation. + * @param abortController An AbortController that can be used to abort the operation. + * @returns A promise that resolves with the new credential. + */ + createCredential: ( + params: CreateCredentialParams, + tab: chrome.tabs.Tab, + abortController?: AbortController + ) => Promise; + + /** + * Allows WebAuthn Relying Party scripts to discover and use an existing public key credential, with the user’s consent. + * Relying Party script can optionally specify some criteria to indicate what credential sources are acceptable to it. + * For more information please see: https://www.w3.org/TR/webauthn-3/#sctn-getAssertion + * + * @param params The parameters for the credential assertion operation. + * @param abortController An AbortController that can be used to abort the operation. + * @returns A promise that resolves with the asserted credential. + */ + assertCredential: ( + params: AssertCredentialParams, + tab: chrome.tabs.Tab, + abortController?: AbortController + ) => Promise; + + isFido2FeatureEnabled: () => Promise; +} + +/** + * Parameters for creating a new credential. + */ +export interface CreateCredentialParams { + /** The Relaying Parties origin, see: https://html.spec.whatwg.org/multipage/browsers.html#concept-origin */ + origin: string; + /** + * A value which is true if and only if the caller’s environment settings object is same-origin with its ancestors. + * It is false if caller is cross-origin. + * */ + sameOriginWithAncestors: boolean; + /** The Relying Party's preference for attestation conveyance */ + attestation?: "direct" | "enterprise" | "indirect" | "none"; + /** The Relying Party's requirements of the authenticator used in the creation of the credential. */ + authenticatorSelection?: { + // authenticatorAttachment?: AuthenticatorAttachment; // not used + requireResidentKey?: boolean; + residentKey?: "discouraged" | "preferred" | "required"; + userVerification?: UserVerification; + }; + /** Challenge intended to be used for generating the newly created credential's attestation object. */ + challenge: string; // b64 encoded + /** + * This member is intended for use by Relying Parties that wish to limit the creation of multiple credentials for + * the same account on a single authenticator. The client is requested to return an error if the new credential would + * be created on an authenticator that also contains one of the credentials enumerated in this parameter. + * */ + excludeCredentials?: { + id: string; // b64 encoded + transports?: ("ble" | "hybrid" | "internal" | "nfc" | "usb")[]; + type: "public-key"; + }[]; + /** + * This member contains additional parameters requesting additional processing by the client and authenticator. + * Not currently supported. + **/ + extensions?: { + appid?: string; + appidExclude?: string; + credProps?: boolean; + uvm?: boolean; + }; + /** + * This member contains information about the desired properties of the credential to be created. + * The sequence is ordered from most preferred to least preferred. + * The client makes a best-effort to create the most preferred credential that it can. + */ + pubKeyCredParams: PublicKeyCredentialParam[]; + /** Data about the Relying Party responsible for the request. */ + rp: { + id?: string; + name: string; + }; + /** Data about the user account for which the Relying Party is requesting attestation. */ + user: { + id: string; // b64 encoded + displayName: string; + }; + /** Forwarded to user interface */ + fallbackSupported: boolean; + /** + * This member specifies a time, in milliseconds, that the caller is willing to wait for the call to complete. + * This is treated as a hint, and MAY be overridden by the client. + **/ + timeout?: number; +} + +/** + * The result of creating a new credential. + */ +export interface CreateCredentialResult { + credentialId: string; + clientDataJSON: string; + attestationObject: string; + authData: string; + publicKeyAlgorithm: number; + transports: string[]; +} + +/** + * Parameters for asserting a credential. + */ +export interface AssertCredentialParams { + allowedCredentialIds: string[]; + rpId: string; + origin: string; + challenge: string; + userVerification?: UserVerification; + timeout: number; + sameOriginWithAncestors: boolean; + fallbackSupported: boolean; +} + +/** + * The result of asserting a credential. + */ +export interface AssertCredentialResult { + credentialId: string; + clientDataJSON: string; + authenticatorData: string; + signature: string; + userHandle: string; +} + +/** + * A description of a key type and algorithm. + * + * @example { + * alg: -7, // ES256 + * type: "public-key" + * } + */ +export interface PublicKeyCredentialParam { + alg: number; + type: "public-key"; +} + +/** + * Error thrown when the user requests a fallback to the browser's built-in WebAuthn implementation. + */ +export class FallbackRequestedError extends Error { + readonly fallbackRequested = true; + constructor() { + super("FallbackRequested"); + } +} diff --git a/libs/common/src/vault/abstractions/fido2/fido2-user-interface.service.abstraction.ts b/libs/common/src/vault/abstractions/fido2/fido2-user-interface.service.abstraction.ts new file mode 100644 index 00000000000..fe15aec0fdc --- /dev/null +++ b/libs/common/src/vault/abstractions/fido2/fido2-user-interface.service.abstraction.ts @@ -0,0 +1,103 @@ +/** + * Parameters used to ask the user to confirm the creation of a new credential. + */ +export interface NewCredentialParams { + /** + * The name of the credential. + */ + credentialName: string; + + /** + * The name of the user. + */ + userName: string; + + /** + * Whether or not the user must be verified before completing the operation. + */ + userVerification: boolean; +} + +/** + * Parameters used to ask the user to pick a credential from a list of existing credentials. + */ +export interface PickCredentialParams { + /** + * The IDs of the credentials that the user can pick from. + */ + cipherIds: string[]; + + /** + * Whether or not the user must be verified before completing the operation. + */ + userVerification: boolean; +} + +/** + * This service is used to provide a user interface with which the user can control FIDO2 operations. + * It acts as a way to remote control the user interface from the background script. + * + * The service is session based and is intended to be used by the FIDO2 authenticator to open a window, + * and then use this window to ask the user for input and/or display messages to the user. + */ +export abstract class Fido2UserInterfaceService { + /** + * Creates a new session. + * Note: This will not necessarily open a window until it is needed to request something from the user. + * + * @param fallbackSupported Whether or not the browser natively supports WebAuthn. + * @param abortController An abort controller that can be used to cancel/close the session. + */ + newSession: ( + fallbackSupported: boolean, + tab: chrome.tabs.Tab, + abortController?: AbortController + ) => Promise; +} + +export abstract class Fido2UserInterfaceSession { + /** + * Ask the user to pick a credential from a list of existing credentials. + * + * @param params The parameters to use when asking the user to pick a credential. + * @param abortController An abort controller that can be used to cancel/close the session. + * @returns The ID of the cipher that contains the credentials the user picked. + */ + pickCredential: ( + params: PickCredentialParams + ) => Promise<{ cipherId: string; userVerified: boolean }>; + + /** + * Ask the user to confirm the creation of a new credential. + * + * @param params The parameters to use when asking the user to confirm the creation of a new credential. + * @param abortController An abort controller that can be used to cancel/close the session. + * @returns The ID of the cipher where the new credential should be saved. + */ + confirmNewCredential: ( + params: NewCredentialParams + ) => Promise<{ cipherId: string; userVerified: boolean }>; + + /** + * Make sure that the vault is unlocked. + * This will open a window and ask the user to login or unlock the vault if necessary. + */ + ensureUnlockedVault: () => Promise; + + /** + * Inform the user that the operation was cancelled because their vault contains excluded credentials. + * + * @param existingCipherIds The IDs of the excluded credentials. + */ + informExcludedCredential: (existingCipherIds: string[]) => Promise; + + /** + * Inform the user that the operation was cancelled because their vault does not contain any useable credentials. + */ + informCredentialNotFound: (abortController?: AbortController) => Promise; + + /** + * Close the session, including any windows that may be open. + */ + close: () => void; +} diff --git a/libs/common/src/vault/api/fido2-credential.api.ts b/libs/common/src/vault/api/fido2-credential.api.ts new file mode 100644 index 00000000000..bfe32fc9b56 --- /dev/null +++ b/libs/common/src/vault/api/fido2-credential.api.ts @@ -0,0 +1,36 @@ +import { BaseResponse } from "../../models/response/base.response"; + +export class Fido2CredentialApi extends BaseResponse { + credentialId: string; + keyType: "public-key"; + keyAlgorithm: "ECDSA"; + keyCurve: "P-256"; + keyValue: string; + rpId: string; + userHandle: string; + counter: string; + rpName: string; + userDisplayName: string; + discoverable: string; + creationDate: string; + + constructor(data: any = null) { + super(data); + if (data == null) { + return; + } + + this.credentialId = this.getResponseProperty("CredentialId"); + this.keyType = this.getResponseProperty("KeyType"); + this.keyAlgorithm = this.getResponseProperty("KeyAlgorithm"); + this.keyCurve = this.getResponseProperty("KeyCurve"); + this.keyValue = this.getResponseProperty("keyValue"); + this.rpId = this.getResponseProperty("RpId"); + this.userHandle = this.getResponseProperty("UserHandle"); + this.counter = this.getResponseProperty("Counter"); + this.rpName = this.getResponseProperty("RpName"); + this.userDisplayName = this.getResponseProperty("UserDisplayName"); + this.discoverable = this.getResponseProperty("Discoverable"); + this.creationDate = this.getResponseProperty("CreationDate"); + } +} diff --git a/libs/common/src/vault/interfaces/launchable.ts b/libs/common/src/vault/interfaces/launchable.ts new file mode 100644 index 00000000000..512fc15419b --- /dev/null +++ b/libs/common/src/vault/interfaces/launchable.ts @@ -0,0 +1,4 @@ +export interface Launchable { + launchUri: string; + canLaunch: boolean; +} diff --git a/libs/common/src/vault/models/data/fido2-credential.data.ts b/libs/common/src/vault/models/data/fido2-credential.data.ts new file mode 100644 index 00000000000..8f5160d91ba --- /dev/null +++ b/libs/common/src/vault/models/data/fido2-credential.data.ts @@ -0,0 +1,35 @@ +import { Fido2CredentialApi } from "../../api/fido2-credential.api"; + +export class Fido2CredentialData { + credentialId: string; + keyType: "public-key"; + keyAlgorithm: "ECDSA"; + keyCurve: "P-256"; + keyValue: string; + rpId: string; + userHandle: string; + counter: string; + rpName: string; + userDisplayName: string; + discoverable: string; + creationDate: string; + + constructor(data?: Fido2CredentialApi) { + if (data == null) { + return; + } + + this.credentialId = data.credentialId; + this.keyType = data.keyType; + this.keyAlgorithm = data.keyAlgorithm; + this.keyCurve = data.keyCurve; + this.keyValue = data.keyValue; + this.rpId = data.rpId; + this.userHandle = data.userHandle; + this.counter = data.counter; + this.rpName = data.rpName; + this.userDisplayName = data.userDisplayName; + this.discoverable = data.discoverable; + this.creationDate = data.creationDate; + } +} diff --git a/libs/common/src/vault/models/data/login.data.ts b/libs/common/src/vault/models/data/login.data.ts index 585b46ac05b..0d8c71e77b4 100644 --- a/libs/common/src/vault/models/data/login.data.ts +++ b/libs/common/src/vault/models/data/login.data.ts @@ -1,5 +1,6 @@ import { LoginApi } from "../../../models/api/login.api"; +import { Fido2CredentialData } from "./fido2-credential.data"; import { LoginUriData } from "./login-uri.data"; export class LoginData { @@ -9,6 +10,7 @@ export class LoginData { passwordRevisionDate: string; totp: string; autofillOnPageLoad: boolean; + fido2Credentials?: Fido2CredentialData[]; constructor(data?: LoginApi) { if (data == null) { @@ -24,5 +26,9 @@ export class LoginData { if (data.uris) { this.uris = data.uris.map((u) => new LoginUriData(u)); } + + if (data.fido2Credentials) { + this.fido2Credentials = data.fido2Credentials?.map((key) => new Fido2CredentialData(key)); + } } } diff --git a/libs/common/src/vault/models/domain/fido2-credential.spec.ts b/libs/common/src/vault/models/domain/fido2-credential.spec.ts new file mode 100644 index 00000000000..0b2b76a19cd --- /dev/null +++ b/libs/common/src/vault/models/domain/fido2-credential.spec.ts @@ -0,0 +1,167 @@ +import { mockEnc } from "../../../../spec"; +import { EncryptionType } from "../../../enums"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { Fido2CredentialData } from "../data/fido2-credential.data"; + +import { Fido2Credential } from "./fido2-credential"; + +describe("Fido2Credential", () => { + let mockDate: Date; + + beforeEach(() => { + mockDate = new Date("2023-01-01T12:00:00.000Z"); + }); + + describe("constructor", () => { + it("returns all fields null when given empty data parameter", () => { + const data = new Fido2CredentialData(); + const credential = new Fido2Credential(data); + + expect(credential).toEqual({ + credentialId: null, + keyType: null, + keyAlgorithm: null, + keyCurve: null, + keyValue: null, + rpId: null, + userHandle: null, + rpName: null, + userDisplayName: null, + counter: null, + discoverable: null, + creationDate: null, + }); + }); + + it("returns all fields as EncStrings except creationDate when given full Fido2CredentialData", () => { + const data: Fido2CredentialData = { + credentialId: "credentialId", + keyType: "public-key", + keyAlgorithm: "ECDSA", + keyCurve: "P-256", + keyValue: "keyValue", + rpId: "rpId", + userHandle: "userHandle", + counter: "counter", + rpName: "rpName", + userDisplayName: "userDisplayName", + discoverable: "discoverable", + creationDate: mockDate.toISOString(), + }; + const credential = new Fido2Credential(data); + + expect(credential).toEqual({ + credentialId: { encryptedString: "credentialId", encryptionType: 0 }, + keyType: { encryptedString: "public-key", encryptionType: 0 }, + keyAlgorithm: { encryptedString: "ECDSA", encryptionType: 0 }, + keyCurve: { encryptedString: "P-256", encryptionType: 0 }, + keyValue: { encryptedString: "keyValue", encryptionType: 0 }, + rpId: { encryptedString: "rpId", encryptionType: 0 }, + userHandle: { encryptedString: "userHandle", encryptionType: 0 }, + counter: { encryptedString: "counter", encryptionType: 0 }, + rpName: { encryptedString: "rpName", encryptionType: 0 }, + userDisplayName: { encryptedString: "userDisplayName", encryptionType: 0 }, + discoverable: { encryptedString: "discoverable", encryptionType: 0 }, + creationDate: mockDate, + }); + }); + + it("should not populate fields when data parameter is not given", () => { + const credential = new Fido2Credential(); + + expect(credential).toEqual({ + credentialId: null, + }); + }); + }); + + describe("decrypt", () => { + it("decrypts and populates all fields when populated with EncStrings", async () => { + const credential = new Fido2Credential(); + credential.credentialId = mockEnc("credentialId"); + credential.keyType = mockEnc("keyType"); + credential.keyAlgorithm = mockEnc("keyAlgorithm"); + credential.keyCurve = mockEnc("keyCurve"); + credential.keyValue = mockEnc("keyValue"); + credential.rpId = mockEnc("rpId"); + credential.userHandle = mockEnc("userHandle"); + credential.counter = mockEnc("2"); + credential.rpName = mockEnc("rpName"); + credential.userDisplayName = mockEnc("userDisplayName"); + credential.discoverable = mockEnc("true"); + credential.creationDate = mockDate; + + const credentialView = await credential.decrypt(null); + + expect(credentialView).toEqual({ + credentialId: "credentialId", + keyType: "keyType", + keyAlgorithm: "keyAlgorithm", + keyCurve: "keyCurve", + keyValue: "keyValue", + rpId: "rpId", + userHandle: "userHandle", + rpName: "rpName", + userDisplayName: "userDisplayName", + counter: 2, + discoverable: true, + creationDate: mockDate, + }); + }); + }); + + describe("toFido2CredentialData", () => { + it("encodes to data object when converted from Fido2CredentialData and back", () => { + const data: Fido2CredentialData = { + credentialId: "credentialId", + keyType: "public-key", + keyAlgorithm: "ECDSA", + keyCurve: "P-256", + keyValue: "keyValue", + rpId: "rpId", + userHandle: "userHandle", + counter: "2", + rpName: "rpName", + userDisplayName: "userDisplayName", + discoverable: "true", + creationDate: mockDate.toISOString(), + }; + + const credential = new Fido2Credential(data); + const result = credential.toFido2CredentialData(); + + expect(result).toEqual(data); + }); + }); + + describe("fromJSON", () => { + it("recreates equivalent object when converted to JSON and back", () => { + const credential = new Fido2Credential(); + credential.credentialId = createEncryptedEncString("credentialId"); + credential.keyType = createEncryptedEncString("keyType"); + credential.keyAlgorithm = createEncryptedEncString("keyAlgorithm"); + credential.keyCurve = createEncryptedEncString("keyCurve"); + credential.keyValue = createEncryptedEncString("keyValue"); + credential.rpId = createEncryptedEncString("rpId"); + credential.userHandle = createEncryptedEncString("userHandle"); + credential.counter = createEncryptedEncString("2"); + credential.rpName = createEncryptedEncString("rpName"); + credential.userDisplayName = createEncryptedEncString("userDisplayName"); + credential.discoverable = createEncryptedEncString("discoverable"); + credential.creationDate = mockDate; + + const json = JSON.stringify(credential); + const result = Fido2Credential.fromJSON(JSON.parse(json)); + + expect(result).toEqual(credential); + }); + + it("returns null if input is null", () => { + expect(Fido2Credential.fromJSON(null)).toBeNull(); + }); + }); +}); + +function createEncryptedEncString(s: string): EncString { + return new EncString(`${EncryptionType.AesCbc256_HmacSha256_B64}.${s}`); +} diff --git a/libs/common/src/vault/models/domain/fido2-credential.ts b/libs/common/src/vault/models/domain/fido2-credential.ts new file mode 100644 index 00000000000..84f075458a8 --- /dev/null +++ b/libs/common/src/vault/models/domain/fido2-credential.ts @@ -0,0 +1,146 @@ +import { Jsonify } from "type-fest"; + +import Domain from "../../../platform/models/domain/domain-base"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { Fido2CredentialData } from "../data/fido2-credential.data"; +import { Fido2CredentialView } from "../view/fido2-credential.view"; + +export class Fido2Credential extends Domain { + credentialId: EncString | null = null; + keyType: EncString; + keyAlgorithm: EncString; + keyCurve: EncString; + keyValue: EncString; + rpId: EncString; + userHandle: EncString; + counter: EncString; + rpName: EncString; + userDisplayName: EncString; + discoverable: EncString; + creationDate: Date; + + constructor(obj?: Fido2CredentialData) { + super(); + if (obj == null) { + return; + } + + this.buildDomainModel( + this, + obj, + { + credentialId: null, + keyType: null, + keyAlgorithm: null, + keyCurve: null, + keyValue: null, + rpId: null, + userHandle: null, + counter: null, + rpName: null, + userDisplayName: null, + discoverable: null, + }, + [] + ); + this.creationDate = obj.creationDate != null ? new Date(obj.creationDate) : null; + } + + async decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise { + const view = await this.decryptObj( + new Fido2CredentialView(), + { + credentialId: null, + keyType: null, + keyAlgorithm: null, + keyCurve: null, + keyValue: null, + rpId: null, + userHandle: null, + rpName: null, + userDisplayName: null, + discoverable: null, + }, + orgId, + encKey + ); + + const { counter } = await this.decryptObj( + { counter: "" }, + { + counter: null, + }, + orgId, + encKey + ); + // Counter will end up as NaN if this fails + view.counter = parseInt(counter); + + const { discoverable } = await this.decryptObj( + { discoverable: "" }, + { + discoverable: null, + }, + orgId, + encKey + ); + view.discoverable = discoverable === "true"; + view.creationDate = this.creationDate; + + return view; + } + + toFido2CredentialData(): Fido2CredentialData { + const i = new Fido2CredentialData(); + i.creationDate = this.creationDate.toISOString(); + this.buildDataModel(this, i, { + credentialId: null, + keyType: null, + keyAlgorithm: null, + keyCurve: null, + keyValue: null, + rpId: null, + userHandle: null, + counter: null, + rpName: null, + userDisplayName: null, + discoverable: null, + }); + return i; + } + + static fromJSON(obj: Jsonify): Fido2Credential { + if (obj == null) { + return null; + } + + const credentialId = EncString.fromJSON(obj.credentialId); + const keyType = EncString.fromJSON(obj.keyType); + const keyAlgorithm = EncString.fromJSON(obj.keyAlgorithm); + const keyCurve = EncString.fromJSON(obj.keyCurve); + const keyValue = EncString.fromJSON(obj.keyValue); + const rpId = EncString.fromJSON(obj.rpId); + const userHandle = EncString.fromJSON(obj.userHandle); + const counter = EncString.fromJSON(obj.counter); + const rpName = EncString.fromJSON(obj.rpName); + const userDisplayName = EncString.fromJSON(obj.userDisplayName); + const discoverable = EncString.fromJSON(obj.discoverable); + const creationDate = obj.creationDate != null ? new Date(obj.creationDate) : null; + + return Object.assign(new Fido2Credential(), obj, { + credentialId, + keyType, + keyAlgorithm, + keyCurve, + keyValue, + rpId, + userHandle, + counter, + rpName, + userDisplayName, + discoverable, + creationDate, + }); + } +} diff --git a/libs/common/src/vault/models/domain/login.spec.ts b/libs/common/src/vault/models/domain/login.spec.ts index 0bd395b3406..d0b24d8c173 100644 --- a/libs/common/src/vault/models/domain/login.spec.ts +++ b/libs/common/src/vault/models/domain/login.spec.ts @@ -3,10 +3,15 @@ import { mock } from "jest-mock-extended"; import { mockEnc, mockFromJson } from "../../../../spec"; import { UriMatchType } from "../../../enums"; import { EncryptedString, EncString } from "../../../platform/models/domain/enc-string"; +import { Fido2CredentialApi } from "../../api/fido2-credential.api"; import { LoginData } from "../../models/data/login.data"; import { Login } from "../../models/domain/login"; import { LoginUri } from "../../models/domain/login-uri"; import { LoginUriView } from "../../models/view/login-uri.view"; +import { Fido2CredentialData } from "../data/fido2-credential.data"; +import { Fido2CredentialView } from "../view/fido2-credential.view"; + +import { Fido2Credential } from "./fido2-credential"; describe("Login DTO", () => { it("Convert from empty LoginData", () => { @@ -23,6 +28,7 @@ describe("Login DTO", () => { }); it("Convert from full LoginData", () => { + const fido2CredentialData = initializeFido2Credential(new Fido2CredentialData()); const data: LoginData = { uris: [{ uri: "uri", match: UriMatchType.Domain }], username: "username", @@ -30,6 +36,7 @@ describe("Login DTO", () => { passwordRevisionDate: "2022-01-31T12:00:00.000Z", totp: "123", autofillOnPageLoad: false, + fido2Credentials: [fido2CredentialData], }; const login = new Login(data); @@ -40,6 +47,7 @@ describe("Login DTO", () => { password: { encryptedString: "password", encryptionType: 0 }, totp: { encryptedString: "123", encryptionType: 0 }, uris: [{ match: 0, uri: { encryptedString: "uri", encryptionType: 0 } }], + fido2Credentials: [encryptFido2Credential(fido2CredentialData)], }); }); @@ -56,12 +64,16 @@ describe("Login DTO", () => { loginUri.decrypt.mockResolvedValue(loginUriView); const login = new Login(); + const decryptedFido2Credential = Symbol(); login.uris = [loginUri]; login.username = mockEnc("encrypted username"); login.password = mockEnc("encrypted password"); login.passwordRevisionDate = new Date("2022-01-31T12:00:00.000Z"); login.totp = mockEnc("encrypted totp"); login.autofillOnPageLoad = true; + login.fido2Credentials = [ + { decrypt: jest.fn().mockReturnValue(decryptedFido2Credential) } as any, + ]; const loginView = await login.decrypt(null); expect(loginView).toEqual({ @@ -80,6 +92,7 @@ describe("Login DTO", () => { }, ], autofillOnPageLoad: true, + fido2Credentials: [decryptedFido2Credential], }); }); @@ -91,6 +104,7 @@ describe("Login DTO", () => { passwordRevisionDate: "2022-01-31T12:00:00.000Z", totp: "123", autofillOnPageLoad: false, + fido2Credentials: [initializeFido2Credential(new Fido2CredentialData())], }; const login = new Login(data); @@ -104,6 +118,7 @@ describe("Login DTO", () => { jest.spyOn(EncString, "fromJSON").mockImplementation(mockFromJson); jest.spyOn(LoginUri, "fromJSON").mockImplementation(mockFromJson); const passwordRevisionDate = new Date("2022-01-31T12:00:00.000Z"); + const fido2CreationDate = new Date("2023-01-01T12:00:00.000Z"); const actual = Login.fromJSON({ uris: ["loginUri1", "loginUri2"] as any, @@ -111,6 +126,22 @@ describe("Login DTO", () => { password: "myPassword" as EncryptedString, passwordRevisionDate: passwordRevisionDate.toISOString(), totp: "myTotp" as EncryptedString, + fido2Credentials: [ + { + credentialId: "keyId" as EncryptedString, + keyType: "keyType" as EncryptedString, + keyAlgorithm: "keyAlgorithm" as EncryptedString, + keyCurve: "keyCurve" as EncryptedString, + keyValue: "keyValue" as EncryptedString, + rpId: "rpId" as EncryptedString, + userHandle: "userHandle" as EncryptedString, + counter: "counter" as EncryptedString, + rpName: "rpName" as EncryptedString, + userDisplayName: "userDisplayName" as EncryptedString, + discoverable: "discoverable" as EncryptedString, + creationDate: fido2CreationDate.toISOString(), + }, + ], }); expect(actual).toEqual({ @@ -119,6 +150,22 @@ describe("Login DTO", () => { password: "myPassword_fromJSON", passwordRevisionDate: passwordRevisionDate, totp: "myTotp_fromJSON", + fido2Credentials: [ + { + credentialId: "keyId_fromJSON", + keyType: "keyType_fromJSON", + keyAlgorithm: "keyAlgorithm_fromJSON", + keyCurve: "keyCurve_fromJSON", + keyValue: "keyValue_fromJSON", + rpId: "rpId_fromJSON", + userHandle: "userHandle_fromJSON", + counter: "counter_fromJSON", + rpName: "rpName_fromJSON", + userDisplayName: "userDisplayName_fromJSON", + discoverable: "discoverable_fromJSON", + creationDate: fido2CreationDate, + }, + ], }); expect(actual).toBeInstanceOf(Login); }); @@ -128,3 +175,42 @@ describe("Login DTO", () => { }); }); }); + +type Fido2CredentialLike = Fido2CredentialData | Fido2CredentialView | Fido2CredentialApi; +function initializeFido2Credential(key: T): T { + key.credentialId = "credentialId"; + key.keyType = "public-key"; + key.keyAlgorithm = "ECDSA"; + key.keyCurve = "P-256"; + key.keyValue = "keyValue"; + key.rpId = "rpId"; + key.userHandle = "userHandle"; + key.counter = "counter"; + key.rpName = "rpName"; + key.userDisplayName = "userDisplayName"; + key.discoverable = "discoverable"; + key.creationDate = "2023-01-01T12:00:00.000Z"; + return key; +} + +function encryptFido2Credential(key: Fido2CredentialLike): Fido2Credential { + const encrypted = new Fido2Credential(); + encrypted.credentialId = { encryptedString: key.credentialId, encryptionType: 0 } as EncString; + encrypted.keyType = { encryptedString: key.keyType, encryptionType: 0 } as EncString; + encrypted.keyAlgorithm = { encryptedString: key.keyAlgorithm, encryptionType: 0 } as EncString; + encrypted.keyCurve = { encryptedString: key.keyCurve, encryptionType: 0 } as EncString; + encrypted.keyValue = { encryptedString: key.keyValue, encryptionType: 0 } as EncString; + encrypted.rpId = { encryptedString: key.rpId, encryptionType: 0 } as EncString; + encrypted.userHandle = { encryptedString: key.userHandle, encryptionType: 0 } as EncString; + encrypted.counter = { encryptedString: key.counter, encryptionType: 0 } as EncString; + encrypted.rpName = { encryptedString: key.rpName, encryptionType: 0 } as EncString; + encrypted.userDisplayName = { + encryptedString: key.userDisplayName, + encryptionType: 0, + } as EncString; + encrypted.discoverable = { encryptedString: key.discoverable, encryptionType: 0 } as EncString; + + // not encrypted + encrypted.creationDate = new Date(key.creationDate); + return encrypted; +} diff --git a/libs/common/src/vault/models/domain/login.ts b/libs/common/src/vault/models/domain/login.ts index bc046e784db..64517b07558 100644 --- a/libs/common/src/vault/models/domain/login.ts +++ b/libs/common/src/vault/models/domain/login.ts @@ -6,6 +6,7 @@ import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-cr import { LoginData } from "../data/login.data"; import { LoginView } from "../view/login.view"; +import { Fido2Credential } from "./fido2-credential"; import { LoginUri } from "./login-uri"; export class Login extends Domain { @@ -15,6 +16,7 @@ export class Login extends Domain { passwordRevisionDate?: Date; totp: EncString; autofillOnPageLoad: boolean; + fido2Credentials: Fido2Credential[]; constructor(obj?: LoginData) { super(); @@ -42,6 +44,10 @@ export class Login extends Domain { this.uris.push(new LoginUri(u)); }); } + + if (obj.fido2Credentials) { + this.fido2Credentials = obj.fido2Credentials.map((key) => new Fido2Credential(key)); + } } async decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise { @@ -64,6 +70,12 @@ export class Login extends Domain { } } + if (this.fido2Credentials != null) { + view.fido2Credentials = await Promise.all( + this.fido2Credentials.map((key) => key.decrypt(orgId, encKey)) + ); + } + return view; } @@ -85,6 +97,10 @@ export class Login extends Domain { }); } + if (this.fido2Credentials != null && this.fido2Credentials.length > 0) { + l.fido2Credentials = this.fido2Credentials.map((key) => key.toFido2CredentialData()); + } + return l; } @@ -99,13 +115,16 @@ export class Login extends Domain { const passwordRevisionDate = obj.passwordRevisionDate == null ? null : new Date(obj.passwordRevisionDate); const uris = obj.uris?.map((uri: any) => LoginUri.fromJSON(uri)); + const fido2Credentials = + obj.fido2Credentials?.map((key) => Fido2Credential.fromJSON(key)) ?? []; return Object.assign(new Login(), obj, { username, password, totp, - passwordRevisionDate: passwordRevisionDate, - uris: uris, + passwordRevisionDate, + uris, + fido2Credentials, }); } } diff --git a/libs/common/src/vault/models/request/cipher.request.ts b/libs/common/src/vault/models/request/cipher.request.ts index 0f34200e79e..949d36ab051 100644 --- a/libs/common/src/vault/models/request/cipher.request.ts +++ b/libs/common/src/vault/models/request/cipher.request.ts @@ -4,6 +4,7 @@ import { IdentityApi } from "../../../models/api/identity.api"; import { LoginUriApi } from "../../../models/api/login-uri.api"; import { LoginApi } from "../../../models/api/login.api"; import { SecureNoteApi } from "../../../models/api/secure-note.api"; +import { Fido2CredentialApi } from "../../api/fido2-credential.api"; import { CipherRepromptType } from "../../enums/cipher-reprompt-type"; import { CipherType } from "../../enums/cipher-type"; import { Cipher } from "../domain/cipher"; @@ -63,6 +64,31 @@ export class CipherRequest { return uri; }); } + + if (cipher.login.fido2Credentials != null) { + this.login.fido2Credentials = cipher.login.fido2Credentials.map((key) => { + const keyApi = new Fido2CredentialApi(); + keyApi.credentialId = + key.credentialId != null ? key.credentialId.encryptedString : null; + keyApi.keyType = + key.keyType != null ? (key.keyType.encryptedString as "public-key") : null; + keyApi.keyAlgorithm = + key.keyAlgorithm != null ? (key.keyAlgorithm.encryptedString as "ECDSA") : null; + keyApi.keyCurve = + key.keyCurve != null ? (key.keyCurve.encryptedString as "P-256") : null; + keyApi.keyValue = key.keyValue != null ? key.keyValue.encryptedString : null; + keyApi.rpId = key.rpId != null ? key.rpId.encryptedString : null; + keyApi.rpName = key.rpName != null ? key.rpName.encryptedString : null; + keyApi.counter = key.counter != null ? key.counter.encryptedString : null; + keyApi.userHandle = key.userHandle != null ? key.userHandle.encryptedString : null; + keyApi.userDisplayName = + key.userDisplayName != null ? key.userDisplayName.encryptedString : null; + keyApi.discoverable = + key.discoverable != null ? key.discoverable.encryptedString : null; + keyApi.creationDate = key.creationDate != null ? key.creationDate.toISOString() : null; + return keyApi; + }); + } break; case CipherType.SecureNote: this.secureNote = new SecureNoteApi(); diff --git a/libs/common/src/vault/models/view/fido2-credential.view.ts b/libs/common/src/vault/models/view/fido2-credential.view.ts new file mode 100644 index 00000000000..b6894e84ff5 --- /dev/null +++ b/libs/common/src/vault/models/view/fido2-credential.view.ts @@ -0,0 +1,29 @@ +import { Jsonify } from "type-fest"; + +import { ItemView } from "./item.view"; + +export class Fido2CredentialView extends ItemView { + credentialId: string; + keyType: "public-key"; + keyAlgorithm: "ECDSA"; + keyCurve: "P-256"; + keyValue: string; + rpId: string; + userHandle: string; + counter: number; + rpName: string; + userDisplayName: string; + discoverable: boolean; + creationDate: Date = null; + + get subTitle(): string { + return this.userDisplayName; + } + + static fromJSON(obj: Partial>): Fido2CredentialView { + const creationDate = obj.creationDate != null ? new Date(obj.creationDate) : null; + return Object.assign(new Fido2CredentialView(), obj, { + creationDate, + }); + } +} diff --git a/libs/common/src/vault/models/view/login.view.ts b/libs/common/src/vault/models/view/login.view.ts index 954a14fe8e9..fa05189ba0c 100644 --- a/libs/common/src/vault/models/view/login.view.ts +++ b/libs/common/src/vault/models/view/login.view.ts @@ -5,6 +5,7 @@ import { linkedFieldOption } from "../../../misc/linkedFieldOption.decorator"; import { Utils } from "../../../platform/misc/utils"; import { Login } from "../domain/login"; +import { Fido2CredentialView } from "./fido2-credential.view"; import { ItemView } from "./item.view"; import { LoginUriView } from "./login-uri.view"; @@ -18,6 +19,7 @@ export class LoginView extends ItemView { totp: string = null; uris: LoginUriView[] = null; autofillOnPageLoad: boolean = null; + fido2Credentials: Fido2CredentialView[] = null; constructor(l?: Login) { super(); @@ -63,6 +65,10 @@ export class LoginView extends ItemView { return this.uris != null && this.uris.length > 0; } + get hasFido2Credentials(): boolean { + return this.fido2Credentials != null && this.fido2Credentials.length > 0; + } + matchesUri( targetUri: string, equivalentDomains: Set, @@ -79,10 +85,12 @@ export class LoginView extends ItemView { const passwordRevisionDate = obj.passwordRevisionDate == null ? null : new Date(obj.passwordRevisionDate); const uris = obj.uris?.map((uri: any) => LoginUriView.fromJSON(uri)); + const fido2Credentials = obj.fido2Credentials?.map((key) => Fido2CredentialView.fromJSON(key)); return Object.assign(new LoginView(), obj, { - passwordRevisionDate: passwordRevisionDate, - uris: uris, + passwordRevisionDate, + uris, + fido2Credentials, }); } } diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index b9bbc3e291e..b5090a14883 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -30,6 +30,7 @@ import { CipherData } from "../models/data/cipher.data"; import { Attachment } from "../models/domain/attachment"; import { Card } from "../models/domain/card"; import { Cipher } from "../models/domain/cipher"; +import { Fido2Credential } from "../models/domain/fido2-credential"; import { Field } from "../models/domain/field"; import { Identity } from "../models/domain/identity"; import { Login } from "../models/domain/login"; @@ -1136,6 +1137,38 @@ export class CipherService implements CipherServiceAbstraction { cipher.login.uris.push(loginUri); } } + + if (model.login.fido2Credentials != null) { + cipher.login.fido2Credentials = await Promise.all( + model.login.fido2Credentials.map(async (viewKey) => { + const domainKey = new Fido2Credential(); + await this.encryptObjProperty( + viewKey, + domainKey, + { + credentialId: null, + keyType: null, + keyAlgorithm: null, + keyCurve: null, + keyValue: null, + rpId: null, + rpName: null, + userHandle: null, + userDisplayName: null, + origin: null, + }, + key + ); + domainKey.counter = await this.cryptoService.encrypt(String(viewKey.counter), key); + domainKey.discoverable = await this.cryptoService.encrypt( + String(viewKey.discoverable), + key + ); + domainKey.creationDate = viewKey.creationDate; + return domainKey; + }) + ); + } return; case CipherType.SecureNote: cipher.secureNote = new SecureNote(); diff --git a/libs/common/src/vault/services/fido2/cbor.ts b/libs/common/src/vault/services/fido2/cbor.ts new file mode 100644 index 00000000000..b74822fd4b6 --- /dev/null +++ b/libs/common/src/vault/services/fido2/cbor.ts @@ -0,0 +1,494 @@ +/** +The MIT License (MIT) + +Copyright (c) 2014-2016 Patrick Gansterer +Copyright (c) 2020-present Aaron Huggins + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Exported from GitHub release version 0.4.0 +*/ + +/* eslint-disable */ +/** @hidden */ +const POW_2_24 = 5.960464477539063e-8; +/** @hidden */ +const POW_2_32 = 4294967296; +/** @hidden */ +const POW_2_53 = 9007199254740992; +/** @hidden */ +const DECODE_CHUNK_SIZE = 8192; + +/** @hidden */ +function objectIs(x: any, y: any) { + if (typeof Object.is === "function") return Object.is(x, y); + + // SameValue algorithm + // Steps 1-5, 7-10 + if (x === y) { + // Steps 6.b-6.e: +0 != -0 + return x !== 0 || 1 / x === 1 / y; + } + + // Step 6.a: NaN == NaN + return x !== x && y !== y; +} + +/** A function that extracts tagged values. */ +type TaggedValueFunction = (value: any, tag: number) => TaggedValue; +/** A function that extracts simple values. */ +type SimpleValueFunction = (value: any) => SimpleValue; + +/** Convenience class for structuring a tagged value. */ +export class TaggedValue { + constructor(value: any, tag: number) { + this.value = value; + this.tag = tag; + } + + value: any; + tag: number; +} + +/** Convenience class for structuring a simple value. */ +export class SimpleValue { + constructor(value: any) { + this.value = value; + } + + value: any; +} + +/** + * Converts a Concise Binary Object Representation (CBOR) buffer into an object. + * @param {ArrayBuffer|SharedArrayBuffer} data - A valid CBOR buffer. + * @param {Function} [tagger] - A function that extracts tagged values. This function is called for each member of the object. + * @param {Function} [simpleValue] - A function that extracts simple values. This function is called for each member of the object. + * @returns {any} The CBOR buffer converted to a JavaScript value. + */ +export function decode( + data: ArrayBuffer | SharedArrayBuffer, + tagger?: TaggedValueFunction, + simpleValue?: SimpleValueFunction +): T { + let dataView = new DataView(data); + let ta = new Uint8Array(data); + let offset = 0; + let tagValueFunction: TaggedValueFunction = function (value: number, tag: number): any { + return new TaggedValue(value, tag); + }; + let simpleValFunction: SimpleValueFunction = function (value: number): SimpleValue { + return undefined as unknown as SimpleValue; + }; + + if (typeof tagger === "function") tagValueFunction = tagger; + if (typeof simpleValue === "function") simpleValFunction = simpleValue; + + function commitRead(length: number, value: T): T { + offset += length; + return value; + } + function readArrayBuffer(length: number) { + return commitRead(length, new Uint8Array(data, offset, length)); + } + function readFloat16() { + let tempArrayBuffer = new ArrayBuffer(4); + let tempDataView = new DataView(tempArrayBuffer); + let value = readUint16(); + + let sign = value & 0x8000; + let exponent = value & 0x7c00; + let fraction = value & 0x03ff; + + if (exponent === 0x7c00) exponent = 0xff << 10; + else if (exponent !== 0) exponent += (127 - 15) << 10; + else if (fraction !== 0) return (sign ? -1 : 1) * fraction * POW_2_24; + + tempDataView.setUint32(0, (sign << 16) | (exponent << 13) | (fraction << 13)); + return tempDataView.getFloat32(0); + } + function readFloat32(): number { + return commitRead(4, dataView.getFloat32(offset)); + } + function readFloat64(): number { + return commitRead(8, dataView.getFloat64(offset)); + } + function readUint8(): number { + return commitRead(1, ta[offset]); + } + function readUint16(): number { + return commitRead(2, dataView.getUint16(offset)); + } + function readUint32(): number { + return commitRead(4, dataView.getUint32(offset)); + } + function readUint64(): number { + return readUint32() * POW_2_32 + readUint32(); + } + function readBreak(): boolean { + if (ta[offset] !== 0xff) return false; + offset += 1; + return true; + } + function readLength(additionalInformation: number): number { + if (additionalInformation < 24) return additionalInformation; + if (additionalInformation === 24) return readUint8(); + if (additionalInformation === 25) return readUint16(); + if (additionalInformation === 26) return readUint32(); + if (additionalInformation === 27) return readUint64(); + if (additionalInformation === 31) return -1; + throw new Error("Invalid length encoding"); + } + function readIndefiniteStringLength(majorType: number): number { + let initialByte = readUint8(); + if (initialByte === 0xff) return -1; + let length = readLength(initialByte & 0x1f); + if (length < 0 || initialByte >> 5 !== majorType) + throw new Error("Invalid indefinite length element"); + return length; + } + + function appendUtf16Data(utf16data: number[], length: number) { + for (let i = 0; i < length; ++i) { + let value = readUint8(); + if (value & 0x80) { + if (value < 0xe0) { + value = ((value & 0x1f) << 6) | (readUint8() & 0x3f); + length -= 1; + } else if (value < 0xf0) { + value = ((value & 0x0f) << 12) | ((readUint8() & 0x3f) << 6) | (readUint8() & 0x3f); + length -= 2; + } else { + value = + ((value & 0x0f) << 18) | + ((readUint8() & 0x3f) << 12) | + ((readUint8() & 0x3f) << 6) | + (readUint8() & 0x3f); + length -= 3; + } + } + + if (value < 0x10000) { + utf16data.push(value); + } else { + value -= 0x10000; + utf16data.push(0xd800 | (value >> 10)); + utf16data.push(0xdc00 | (value & 0x3ff)); + } + } + } + + function decodeItem(): any { + let initialByte = readUint8(); + let majorType = initialByte >> 5; + let additionalInformation = initialByte & 0x1f; + let i; + let length; + + if (majorType === 7) { + switch (additionalInformation) { + case 25: + return readFloat16(); + case 26: + return readFloat32(); + case 27: + return readFloat64(); + } + } + + length = readLength(additionalInformation); + if (length < 0 && (majorType < 2 || 6 < majorType)) throw new Error("Invalid length"); + + switch (majorType) { + case 0: + return length; + case 1: + return -1 - length; + case 2: + if (length < 0) { + let elements = []; + let fullArrayLength = 0; + while ((length = readIndefiniteStringLength(majorType)) >= 0) { + fullArrayLength += length; + elements.push(readArrayBuffer(length)); + } + let fullArray = new Uint8Array(fullArrayLength); + let fullArrayOffset = 0; + for (i = 0; i < elements.length; ++i) { + fullArray.set(elements[i], fullArrayOffset); + fullArrayOffset += elements[i].length; + } + return fullArray; + } + return readArrayBuffer(length); + case 3: + let utf16data: number[] = []; + if (length < 0) { + while ((length = readIndefiniteStringLength(majorType)) >= 0) + appendUtf16Data(utf16data, length); + } else { + appendUtf16Data(utf16data, length); + } + let string = ""; + for (i = 0; i < utf16data.length; i += DECODE_CHUNK_SIZE) { + string += String.fromCharCode.apply(null, utf16data.slice(i, i + DECODE_CHUNK_SIZE)); + } + return string; + case 4: + let retArray; + if (length < 0) { + retArray = []; + while (!readBreak()) retArray.push(decodeItem()); + } else { + retArray = new Array(length); + for (i = 0; i < length; ++i) retArray[i] = decodeItem(); + } + return retArray; + case 5: + let retObject: any = {}; + for (i = 0; i < length || (length < 0 && !readBreak()); ++i) { + let key = decodeItem(); + retObject[key] = decodeItem(); + } + return retObject; + case 6: + return tagValueFunction(decodeItem(), length); + case 7: + switch (length) { + case 20: + return false; + case 21: + return true; + case 22: + return null; + case 23: + return undefined; + default: + return simpleValFunction(length); + } + } + } + + let ret = decodeItem(); + if (offset !== data.byteLength) throw new Error("Remaining bytes"); + return ret; +} + +/** + * Converts a JavaScript value to a Concise Binary Object Representation (CBOR) buffer. + * @param {any} value - A JavaScript value, usually an object or array, to be converted. + * @returns {ArrayBuffer} The JavaScript value converted to CBOR format. + */ +export function encode(value: T): ArrayBuffer { + let data = new ArrayBuffer(256); + let dataView = new DataView(data); + let byteView = new Uint8Array(data); + let lastLength: number; + let offset = 0; + + function prepareWrite(length: number): DataView { + let newByteLength = data.byteLength; + let requiredLength = offset + length; + while (newByteLength < requiredLength) newByteLength <<= 1; + if (newByteLength !== data.byteLength) { + let oldDataView = dataView; + data = new ArrayBuffer(newByteLength); + dataView = new DataView(data); + byteView = new Uint8Array(data); + let uint32count = (offset + 3) >> 2; + for (let i = 0; i < uint32count; ++i) + dataView.setUint32(i << 2, oldDataView.getUint32(i << 2)); + } + + lastLength = length; + return dataView; + } + function commitWrite(...args: any[]) { + offset += lastLength; + } + function writeFloat64(val: number) { + commitWrite(prepareWrite(8).setFloat64(offset, val)); + } + function writeUint8(val: number) { + commitWrite(prepareWrite(1).setUint8(offset, val)); + } + function writeUint8Array(val: number[] | Uint8Array) { + prepareWrite(val.length); + byteView.set(val, offset); + commitWrite(); + } + function writeUint16(val: number) { + commitWrite(prepareWrite(2).setUint16(offset, val)); + } + function writeUint32(val: number) { + commitWrite(prepareWrite(4).setUint32(offset, val)); + } + function writeUint64(val: number) { + let low = val % POW_2_32; + let high = (val - low) / POW_2_32; + let view = prepareWrite(8); + view.setUint32(offset, high); + view.setUint32(offset + 4, low); + commitWrite(); + } + function writeVarUint(val: number, mod: number = 0) { + if (val <= 0xff) { + if (val < 24) { + writeUint8(val | mod); + } else { + writeUint8(0x18 | mod); + writeUint8(val); + } + } else if (val <= 0xffff) { + writeUint8(0x19 | mod); + writeUint16(val); + } else if (val <= 0xffffffff) { + writeUint8(0x1a | mod); + writeUint32(val); + } else { + writeUint8(0x1b | mod); + writeUint64(val); + } + } + function writeTypeAndLength(type: number, length: number) { + if (length < 24) { + writeUint8((type << 5) | length); + } else if (length < 0x100) { + writeUint8((type << 5) | 24); + writeUint8(length); + } else if (length < 0x10000) { + writeUint8((type << 5) | 25); + writeUint16(length); + } else if (length < 0x100000000) { + writeUint8((type << 5) | 26); + writeUint32(length); + } else { + writeUint8((type << 5) | 27); + writeUint64(length); + } + } + + function encodeItem(val: any) { + let i; + + if (val === false) return writeUint8(0xf4); + if (val === true) return writeUint8(0xf5); + if (val === null) return writeUint8(0xf6); + if (val === undefined) return writeUint8(0xf7); + if (objectIs(val, -0)) return writeUint8Array([0xf9, 0x80, 0x00]); + + switch (typeof val) { + case "number": + if (Math.floor(val) === val) { + if (0 <= val && val <= POW_2_53) return writeTypeAndLength(0, val); + if (-POW_2_53 <= val && val < 0) return writeTypeAndLength(1, -(val + 1)); + } + writeUint8(0xfb); + return writeFloat64(val); + + case "string": + let utf8data = []; + for (i = 0; i < val.length; ++i) { + let charCode = val.charCodeAt(i); + if (charCode < 0x80) { + utf8data.push(charCode); + } else if (charCode < 0x800) { + utf8data.push(0xc0 | (charCode >> 6)); + utf8data.push(0x80 | (charCode & 0x3f)); + } else if (charCode < 0xd800 || charCode >= 0xe000) { + utf8data.push(0xe0 | (charCode >> 12)); + utf8data.push(0x80 | ((charCode >> 6) & 0x3f)); + utf8data.push(0x80 | (charCode & 0x3f)); + } else { + charCode = (charCode & 0x3ff) << 10; + charCode |= val.charCodeAt(++i) & 0x3ff; + charCode += 0x10000; + + utf8data.push(0xf0 | (charCode >> 18)); + utf8data.push(0x80 | ((charCode >> 12) & 0x3f)); + utf8data.push(0x80 | ((charCode >> 6) & 0x3f)); + utf8data.push(0x80 | (charCode & 0x3f)); + } + } + + writeTypeAndLength(3, utf8data.length); + return writeUint8Array(utf8data); + + default: + let length; + let converted; + if (Array.isArray(val)) { + length = val.length; + writeTypeAndLength(4, length); + for (i = 0; i < length; i += 1) encodeItem(val[i]); + } else if (val instanceof Uint8Array) { + writeTypeAndLength(2, val.length); + writeUint8Array(val); + } else if (ArrayBuffer.isView(val)) { + converted = new Uint8Array(val.buffer); + writeTypeAndLength(2, converted.length); + writeUint8Array(converted); + } else if ( + val instanceof ArrayBuffer || + (typeof SharedArrayBuffer === "function" && val instanceof SharedArrayBuffer) + ) { + converted = new Uint8Array(val); + writeTypeAndLength(2, converted.length); + writeUint8Array(converted); + } else if (val instanceof TaggedValue) { + writeVarUint(val.tag, 0b11000000); + encodeItem(val.value); + } else { + let keys = Object.keys(val); + length = keys.length; + writeTypeAndLength(5, length); + for (i = 0; i < length; i += 1) { + let key = keys[i]; + encodeItem(key); + encodeItem(val[key]); + } + } + } + } + + encodeItem(value); + + if ("slice" in data) return data.slice(0, offset); + + let ret = new ArrayBuffer(offset); + let retView = new DataView(ret); + for (let i = 0; i < offset; ++i) retView.setUint8(i, dataView.getUint8(i)); + return ret; +} + +/** + * An intrinsic object that provides functions to convert JavaScript values + * to and from the Concise Binary Object Representation (CBOR) format. + */ +export const CBOR: { + decode: ( + data: ArrayBuffer | SharedArrayBuffer, + tagger?: TaggedValueFunction, + simpleValue?: SimpleValueFunction + ) => T; + encode: (value: T) => ArrayBuffer; +} = { + decode, + encode, +}; diff --git a/libs/common/src/vault/services/fido2/domain-utils.spec.ts b/libs/common/src/vault/services/fido2/domain-utils.spec.ts new file mode 100644 index 00000000000..7c9c27869a2 --- /dev/null +++ b/libs/common/src/vault/services/fido2/domain-utils.spec.ts @@ -0,0 +1,53 @@ +import { isValidRpId } from "./domain-utils"; + +// Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, return a DOMException whose name is "SecurityError", and terminate this algorithm. +describe("validateRpId", () => { + it("should not be valid when rpId is more specific than origin", () => { + const rpId = "sub.login.bitwarden.com"; + const origin = "https://login.bitwarden.com:1337"; + + expect(isValidRpId(rpId, origin)).toBe(false); + }); + + it("should not be valid when effective domains of rpId and origin do not match", () => { + const rpId = "passwordless.dev"; + const origin = "https://login.bitwarden.com:1337"; + + expect(isValidRpId(rpId, origin)).toBe(false); + }); + + it("should not be valid when subdomains are the same but effective domains of rpId and origin do not match", () => { + const rpId = "login.passwordless.dev"; + const origin = "https://login.bitwarden.com:1337"; + + expect(isValidRpId(rpId, origin)).toBe(false); + }); + + it("should be valid when domains of rpId and origin are the same", () => { + const rpId = "bitwarden.com"; + const origin = "https://bitwarden.com"; + + expect(isValidRpId(rpId, origin)).toBe(true); + }); + + it("should be valid when origin is a subdomain of rpId", () => { + const rpId = "bitwarden.com"; + const origin = "https://login.bitwarden.com:1337"; + + expect(isValidRpId(rpId, origin)).toBe(true); + }); + + it("should be valid when domains of rpId and origin are the same and they are both subdomains", () => { + const rpId = "login.bitwarden.com"; + const origin = "https://login.bitwarden.com:1337"; + + expect(isValidRpId(rpId, origin)).toBe(true); + }); + + it("should be valid when origin is a subdomain of rpId and they are both subdomains", () => { + const rpId = "login.bitwarden.com"; + const origin = "https://sub.login.bitwarden.com:1337"; + + expect(isValidRpId(rpId, origin)).toBe(true); + }); +}); diff --git a/libs/common/src/vault/services/fido2/domain-utils.ts b/libs/common/src/vault/services/fido2/domain-utils.ts new file mode 100644 index 00000000000..20b6e41700d --- /dev/null +++ b/libs/common/src/vault/services/fido2/domain-utils.ts @@ -0,0 +1,11 @@ +import { parse } from "tldts"; + +export function isValidRpId(rpId: string, origin: string) { + const parsedOrigin = parse(origin, { allowPrivateDomains: true }); + const parsedRpId = parse(rpId, { allowPrivateDomains: true }); + + return ( + parsedOrigin.domain === parsedRpId.domain && + parsedOrigin.subdomain.endsWith(parsedRpId.subdomain) + ); +} diff --git a/libs/common/src/vault/services/fido2/ecdsa-utils.ts b/libs/common/src/vault/services/fido2/ecdsa-utils.ts new file mode 100644 index 00000000000..1a4bf64de66 --- /dev/null +++ b/libs/common/src/vault/services/fido2/ecdsa-utils.ts @@ -0,0 +1,124 @@ +/* + Copyright 2015 D2L Corporation + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. */ + +// Changes: +// - Cherry-pick the methods that we have a need for. +// - Add typings. +// - Original code is made for running in node, this version is adapted to work in the browser. + +// https://github.com/Brightspace/node-ecdsa-sig-formatter/blob/master/src/param-bytes-for-alg.js + +function getParamSize(keySize: number) { + const result = ((keySize / 8) | 0) + (keySize % 8 === 0 ? 0 : 1); + return result; +} + +const paramBytesForAlg = { + ES256: getParamSize(256), + ES384: getParamSize(384), + ES512: getParamSize(521), +}; + +type Alg = keyof typeof paramBytesForAlg; + +function getParamBytesForAlg(alg: Alg) { + const paramBytes = paramBytesForAlg[alg]; + if (paramBytes) { + return paramBytes; + } + + throw new Error('Unknown algorithm "' + alg + '"'); +} + +// https://github.com/Brightspace/node-ecdsa-sig-formatter/blob/master/src/ecdsa-sig-formatter.js + +const MAX_OCTET = 0x80, + CLASS_UNIVERSAL = 0, + PRIMITIVE_BIT = 0x20, + TAG_SEQ = 0x10, + TAG_INT = 0x02, + ENCODED_TAG_SEQ = TAG_SEQ | PRIMITIVE_BIT | (CLASS_UNIVERSAL << 6), + ENCODED_TAG_INT = TAG_INT | (CLASS_UNIVERSAL << 6); + +function countPadding(buf: Uint8Array, start: number, stop: number) { + let padding = 0; + while (start + padding < stop && buf[start + padding] === 0) { + ++padding; + } + + const needsSign = buf[start + padding] >= MAX_OCTET; + if (needsSign) { + --padding; + } + + return padding; +} + +export function joseToDer(signature: Uint8Array, alg: Alg) { + const paramBytes = getParamBytesForAlg(alg); + + const signatureBytes = signature.length; + if (signatureBytes !== paramBytes * 2) { + throw new TypeError( + '"' + + alg + + '" signatures must be "' + + paramBytes * 2 + + '" bytes, saw "' + + signatureBytes + + '"' + ); + } + + const rPadding = countPadding(signature, 0, paramBytes); + const sPadding = countPadding(signature, paramBytes, signature.length); + const rLength = paramBytes - rPadding; + const sLength = paramBytes - sPadding; + + const rsBytes = 1 + 1 + rLength + 1 + 1 + sLength; + + const shortLength = rsBytes < MAX_OCTET; + + const dst = new Uint8Array((shortLength ? 2 : 3) + rsBytes); + + let offset = 0; + dst[offset++] = ENCODED_TAG_SEQ; + if (shortLength) { + dst[offset++] = rsBytes; + } else { + dst[offset++] = MAX_OCTET | 1; + dst[offset++] = rsBytes & 0xff; + } + dst[offset++] = ENCODED_TAG_INT; + dst[offset++] = rLength; + if (rPadding < 0) { + dst[offset++] = 0; + dst.set(signature.subarray(0, paramBytes), offset); + offset += paramBytes; + } else { + dst.set(signature.subarray(rPadding, paramBytes), offset); + offset += paramBytes; + } + dst[offset++] = ENCODED_TAG_INT; + dst[offset++] = sLength; + if (sPadding < 0) { + dst[offset++] = 0; + dst.set(signature.subarray(paramBytes), offset); + } else { + dst.set(signature.subarray(paramBytes + sPadding), offset); + } + + return dst; +} diff --git a/libs/common/src/vault/services/fido2/fido2-authenticator.service.spec.ts b/libs/common/src/vault/services/fido2/fido2-authenticator.service.spec.ts new file mode 100644 index 00000000000..c519fccffc2 --- /dev/null +++ b/libs/common/src/vault/services/fido2/fido2-authenticator.service.spec.ts @@ -0,0 +1,826 @@ +import { TextEncoder } from "util"; + +import { mock, MockProxy } from "jest-mock-extended"; + +import { Utils } from "../../../platform/misc/utils"; +import { CipherService } from "../../abstractions/cipher.service"; +import { + Fido2AutenticatorErrorCode, + Fido2AuthenticatorGetAssertionParams, + Fido2AuthenticatorMakeCredentialsParams, +} from "../../abstractions/fido2/fido2-authenticator.service.abstraction"; +import { + Fido2UserInterfaceService, + Fido2UserInterfaceSession, + NewCredentialParams, +} from "../../abstractions/fido2/fido2-user-interface.service.abstraction"; +import { SyncService } from "../../abstractions/sync/sync.service.abstraction"; +import { CipherRepromptType } from "../../enums/cipher-reprompt-type"; +import { CipherType } from "../../enums/cipher-type"; +import { Cipher } from "../../models/domain/cipher"; +import { CipherView } from "../../models/view/cipher.view"; +import { Fido2CredentialView } from "../../models/view/fido2-credential.view"; +import { LoginView } from "../../models/view/login.view"; + +import { CBOR } from "./cbor"; +import { AAGUID, Fido2AuthenticatorService } from "./fido2-authenticator.service"; +import { Fido2Utils } from "./fido2-utils"; +import { guidToRawFormat } from "./guid-utils"; + +const RpId = "bitwarden.com"; + +describe("FidoAuthenticatorService", () => { + let cipherService!: MockProxy; + let userInterface!: MockProxy; + let userInterfaceSession!: MockProxy; + let syncService!: MockProxy; + let authenticator!: Fido2AuthenticatorService; + let tab!: chrome.tabs.Tab; + + beforeEach(async () => { + cipherService = mock(); + userInterface = mock(); + userInterfaceSession = mock(); + userInterface.newSession.mockResolvedValue(userInterfaceSession); + syncService = mock(); + authenticator = new Fido2AuthenticatorService(cipherService, userInterface, syncService); + tab = { id: 123, windowId: 456 } as chrome.tabs.Tab; + }); + + describe("makeCredential", () => { + let invalidParams!: InvalidParams; + + beforeEach(async () => { + invalidParams = await createInvalidParams(); + }); + + describe("invalid input parameters", () => { + // Spec: Check if at least one of the specified combinations of PublicKeyCredentialType and cryptographic parameters in credTypesAndPubKeyAlgs is supported. If not, return an error code equivalent to "NotSupportedError" and terminate the operation. + it("should throw error when input does not contain any supported algorithms", async () => { + const result = async () => + await authenticator.makeCredential(invalidParams.unsupportedAlgorithm, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotSupported); + }); + + it("should throw error when requireResidentKey has invalid value", async () => { + const result = async () => await authenticator.makeCredential(invalidParams.invalidRk, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown); + }); + + it("should throw error when requireUserVerification has invalid value", async () => { + const result = async () => await authenticator.makeCredential(invalidParams.invalidUv, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown); + }); + + /** + * Spec: If requireUserVerification is true and the authenticator cannot perform user verification, return an error code equivalent to "ConstraintError" and terminate the operation. + * Deviation: User verification is checked before checking for excluded credentials + **/ + /** TODO: This test should only be activated if we disable support for user verification */ + it.skip("should throw error if requireUserVerification is set to true", async () => { + const params = await createParams({ requireUserVerification: true }); + + const result = async () => await authenticator.makeCredential(params, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Constraint); + }); + + it("should not request confirmation from user", async () => { + userInterfaceSession.confirmNewCredential.mockResolvedValue({ + cipherId: "75280e7e-a72e-4d6c-bf1e-d37238352f9b", + userVerified: false, + }); + const invalidParams = await createInvalidParams(); + + for (const p of Object.values(invalidParams)) { + try { + await authenticator.makeCredential(p, tab); + // eslint-disable-next-line no-empty + } catch {} + } + expect(userInterfaceSession.confirmNewCredential).not.toHaveBeenCalled(); + }); + }); + + describe.skip("when extensions parameter is present", () => undefined); + + describe("vault contains excluded credential", () => { + let excludedCipher: CipherView; + let params: Fido2AuthenticatorMakeCredentialsParams; + + beforeEach(async () => { + excludedCipher = createCipherView( + { type: CipherType.Login }, + { credentialId: Utils.newGuid() } + ); + params = await createParams({ + excludeCredentialDescriptorList: [ + { + id: guidToRawFormat(excludedCipher.login.fido2Credentials[0].credentialId), + type: "public-key", + }, + ], + }); + cipherService.get.mockImplementation(async (id) => + id === excludedCipher.id ? ({ decrypt: () => excludedCipher } as any) : undefined + ); + cipherService.getAllDecrypted.mockResolvedValue([excludedCipher]); + }); + + /** + * Spec: collect an authorization gesture confirming user consent for creating a new credential. + * Deviation: Consent is not asked and the user is simply informed of the situation. + **/ + it("should inform user", async () => { + userInterfaceSession.informExcludedCredential.mockResolvedValue(); + + try { + await authenticator.makeCredential(params, tab); + // eslint-disable-next-line no-empty + } catch {} + + expect(userInterfaceSession.informExcludedCredential).toHaveBeenCalled(); + }); + + /** Spec: return an error code equivalent to "NotAllowedError" and terminate the operation. */ + it("should throw error", async () => { + userInterfaceSession.informExcludedCredential.mockResolvedValue(); + + const result = async () => await authenticator.makeCredential(params, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed); + }); + + /** Devation: Organization ciphers are not checked against excluded credentials, even if the user has access to them. */ + it("should not inform user of duplication when the excluded credential belongs to an organization", async () => { + userInterfaceSession.informExcludedCredential.mockResolvedValue(); + excludedCipher.organizationId = "someOrganizationId"; + + try { + await authenticator.makeCredential(params, tab); + // eslint-disable-next-line no-empty + } catch {} + + expect(userInterfaceSession.informExcludedCredential).not.toHaveBeenCalled(); + }); + + it("should not inform user of duplication when input data does not pass checks", async () => { + userInterfaceSession.informExcludedCredential.mockResolvedValue(); + const invalidParams = await createInvalidParams(); + + for (const p of Object.values(invalidParams)) { + try { + await authenticator.makeCredential(p, tab); + // eslint-disable-next-line no-empty + } catch {} + } + expect(userInterfaceSession.informExcludedCredential).not.toHaveBeenCalled(); + }); + + it.todo( + "should not throw error if the excluded credential has been marked as deleted in the vault" + ); + }); + + describe("credential creation", () => { + let existingCipher: CipherView; + let params: Fido2AuthenticatorMakeCredentialsParams; + + beforeEach(async () => { + existingCipher = createCipherView({ type: CipherType.Login }); + params = await createParams({ requireResidentKey: false }); + cipherService.get.mockImplementation(async (id) => + id === existingCipher.id ? ({ decrypt: () => existingCipher } as any) : undefined + ); + cipherService.getAllDecrypted.mockResolvedValue([existingCipher]); + }); + + /** + * Spec: Collect an authorization gesture confirming user consent for creating a new credential. The prompt for the authorization gesture is shown by the authenticator if it has its own output capability. The prompt SHOULD display rpEntity.id, rpEntity.name, userEntity.name and userEntity.displayName, if possible. + * Deviation: Only `rpEntity.name` and `userEntity.name` is shown. + * */ + for (const userVerification of [true, false]) { + it(`should request confirmation from user when user verification is ${userVerification}`, async () => { + params.requireUserVerification = userVerification; + userInterfaceSession.confirmNewCredential.mockResolvedValue({ + cipherId: existingCipher.id, + userVerified: userVerification, + }); + + await authenticator.makeCredential(params, tab); + + expect(userInterfaceSession.confirmNewCredential).toHaveBeenCalledWith({ + credentialName: params.rpEntity.name, + userName: params.userEntity.displayName, + userVerification, + } as NewCredentialParams); + }); + } + + it("should save credential to vault if request confirmed by user", async () => { + const encryptedCipher = Symbol(); + userInterfaceSession.confirmNewCredential.mockResolvedValue({ + cipherId: existingCipher.id, + userVerified: false, + }); + cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher); + + await authenticator.makeCredential(params, tab); + + const saved = cipherService.encrypt.mock.lastCall?.[0]; + expect(saved).toEqual( + expect.objectContaining({ + type: CipherType.Login, + name: existingCipher.name, + + login: expect.objectContaining({ + fido2Credentials: [ + expect.objectContaining({ + credentialId: expect.anything(), + keyType: "public-key", + keyAlgorithm: "ECDSA", + keyCurve: "P-256", + rpId: params.rpEntity.id, + rpName: params.rpEntity.name, + userHandle: Fido2Utils.bufferToString(params.userEntity.id), + counter: 0, + userDisplayName: params.userEntity.displayName, + discoverable: false, + }), + ], + }), + }) + ); + expect(cipherService.updateWithServer).toHaveBeenCalledWith(encryptedCipher); + }); + + /** Spec: If the user does not consent or if user verification fails, return an error code equivalent to "NotAllowedError" and terminate the operation. */ + it("should throw error if user denies creation request", async () => { + userInterfaceSession.confirmNewCredential.mockResolvedValue({ + cipherId: undefined, + userVerified: false, + }); + const params = await createParams(); + + const result = async () => await authenticator.makeCredential(params, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed); + }); + + it("should throw error if user verification fails and cipher requires reprompt", async () => { + params.requireUserVerification = false; + userInterfaceSession.confirmNewCredential.mockResolvedValue({ + cipherId: existingCipher.id, + userVerified: false, + }); + const encryptedCipher = { ...existingCipher, reprompt: CipherRepromptType.Password }; + cipherService.get.mockResolvedValue(encryptedCipher as unknown as Cipher); + + const result = async () => await authenticator.makeCredential(params, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown); + }); + + /** Spec: If any error occurred while creating the new credential object, return an error code equivalent to "UnknownError" and terminate the operation. */ + it("should throw unkown error if creation fails", async () => { + const encryptedCipher = Symbol(); + userInterfaceSession.confirmNewCredential.mockResolvedValue({ + cipherId: existingCipher.id, + userVerified: false, + }); + cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher); + cipherService.updateWithServer.mockRejectedValue(new Error("Internal error")); + + const result = async () => await authenticator.makeCredential(params, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown); + }); + }); + + describe(`attestation of new credential`, () => { + const cipherId = "75280e7e-a72e-4d6c-bf1e-d37238352f9b"; + const credentialId = "52217b91-73f1-4fea-b3f2-54a7959fd5aa"; + const credentialIdBytes = new Uint8Array([ + 0x52, 0x21, 0x7b, 0x91, 0x73, 0xf1, 0x4f, 0xea, 0xb3, 0xf2, 0x54, 0xa7, 0x95, 0x9f, 0xd5, + 0xaa, + ]); + let params: Fido2AuthenticatorMakeCredentialsParams; + + beforeEach(async () => { + const cipher = createCipherView({ id: cipherId, type: CipherType.Login }); + params = await createParams(); + userInterfaceSession.confirmNewCredential.mockResolvedValue({ + cipherId, + userVerified: false, + }); + cipherService.get.mockImplementation(async (cipherId) => + cipherId === cipher.id ? ({ decrypt: () => cipher } as any) : undefined + ); + cipherService.getAllDecrypted.mockResolvedValue([await cipher]); + cipherService.encrypt.mockImplementation(async (cipher) => { + cipher.login.fido2Credentials[0].credentialId = credentialId; // Replace id for testability + return {} as any; + }); + cipherService.createWithServer.mockImplementation(async (cipher) => { + cipher.id = cipherId; + return cipher; + }); + cipherService.updateWithServer.mockImplementation(async (cipher) => { + cipher.id = cipherId; + return cipher; + }); + }); + + it("should return attestation object", async () => { + const result = await authenticator.makeCredential(params, tab); + + const attestationObject = CBOR.decode( + Fido2Utils.bufferSourceToUint8Array(result.attestationObject).buffer + ); + + const encAuthData: Uint8Array = attestationObject.authData; + const rpIdHash = encAuthData.slice(0, 32); + const flags = encAuthData.slice(32, 33); + const counter = encAuthData.slice(33, 37); + const aaguid = encAuthData.slice(37, 53); + const credentialIdLength = encAuthData.slice(53, 55); + const credentialId = encAuthData.slice(55, 71); + // Unsure how to test public key + // const publicKey = encAuthData.slice(87); + + expect(encAuthData.length).toBe(71 + 77); + expect(attestationObject.fmt).toBe("none"); + expect(attestationObject.attStmt).toEqual({}); + expect(rpIdHash).toEqual( + new Uint8Array([ + 0x22, 0x6b, 0xb3, 0x92, 0x02, 0xff, 0xf9, 0x22, 0xdc, 0x74, 0x05, 0xcd, 0x28, 0xa8, + 0x34, 0x5a, 0xc4, 0xf2, 0x64, 0x51, 0xd7, 0x3d, 0x0b, 0x40, 0xef, 0xf3, 0x1d, 0xc1, + 0xd0, 0x5c, 0x3d, 0xc3, + ]) + ); + expect(flags).toEqual(new Uint8Array([0b01000001])); // UP = true, AD = true + expect(counter).toEqual(new Uint8Array([0, 0, 0, 0])); // 0 because of new counter + expect(aaguid).toEqual(AAGUID); + expect(credentialIdLength).toEqual(new Uint8Array([0, 16])); // 16 bytes because we're using GUIDs + expect(credentialId).toEqual(credentialIdBytes); + }); + }); + + async function createParams( + params: Partial = {} + ): Promise { + return { + hash: params.hash ?? (await createClientDataHash()), + rpEntity: params.rpEntity ?? { + name: "Bitwarden", + id: RpId, + }, + userEntity: params.userEntity ?? { + id: randomBytes(64), + name: "jane.doe@bitwarden.com", + displayName: "Jane Doe", + icon: " data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAOhJREFUeNpiFI+9E8DAwDAfiAUYSAMfgDiQBVmzlSYnUTqPXf/OANWzngVZ87pKKaIMCGp/BjeEhRjFMKAjx8bQFC2CIs9CpHNxAiYGCsEQM4Cfiwm3AY9f/yZogIcRN4ZahAFv/jAcu4E7xMNtecEYpAakFqsX8me9Yvj07R+G5jR3foaJqWJgOZAaZMAIzAv/kQV05NgZ5hdIMMiKQJIIyEYrDU6wrYkTXjBcefQTvwGwwCoJFGJIBdoMArN3fmToWf+O4SMW14EMeI8rJ8Jcgexn9BwJCoNEaNbEACCN+DSDsjNAgAEAri9Zii/uDMsAAAAASUVORK5CYII=", + }, + credTypesAndPubKeyAlgs: params.credTypesAndPubKeyAlgs ?? [ + { + alg: -7, // ES256 + type: "public-key", + }, + ], + excludeCredentialDescriptorList: params.excludeCredentialDescriptorList ?? [ + { + id: randomBytes(16), + transports: ["internal"], + type: "public-key", + }, + ], + requireResidentKey: params.requireResidentKey ?? false, + requireUserVerification: params.requireUserVerification ?? false, + fallbackSupported: params.fallbackSupported ?? false, + extensions: params.extensions ?? { + appid: undefined, + appidExclude: undefined, + credProps: undefined, + uvm: false as boolean, + }, + }; + } + + type InvalidParams = Awaited>; + async function createInvalidParams() { + return { + unsupportedAlgorithm: await createParams({ + credTypesAndPubKeyAlgs: [{ alg: 9001, type: "public-key" }], + }), + invalidRk: await createParams({ requireResidentKey: "invalid-value" as any }), + invalidUv: await createParams({ + requireUserVerification: "invalid-value" as any, + }), + }; + } + }); + + describe("getAssertion", () => { + let invalidParams!: InvalidParams; + + beforeEach(async () => { + invalidParams = await createInvalidParams(); + }); + + describe("invalid input parameters", () => { + it("should throw error when requireUserVerification has invalid value", async () => { + const result = async () => await authenticator.getAssertion(invalidParams.invalidUv, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown); + }); + + /** + * Spec: If requireUserVerification is true and the authenticator cannot perform user verification, return an error code equivalent to "ConstraintError" and terminate the operation. + * Deviation: User verification is checked before checking for excluded credentials + **/ + /** NOTE: This test should only be activated if we disable support for user verification */ + it.skip("should throw error if requireUserVerification is set to true", async () => { + const params = await createParams({ requireUserVerification: true }); + + const result = async () => await authenticator.getAssertion(params, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Constraint); + }); + }); + + describe("vault is missing non-discoverable credential", () => { + let credentialId: string; + let params: Fido2AuthenticatorGetAssertionParams; + + beforeEach(async () => { + credentialId = Utils.newGuid(); + params = await createParams({ + allowCredentialDescriptorList: [ + { id: guidToRawFormat(credentialId), type: "public-key" }, + ], + rpId: RpId, + }); + }); + + /** + * Spec: If credentialOptions is now empty, return an error code equivalent to "NotAllowedError" and terminate the operation. + * Deviation: We do not throw error but instead inform the user and allow the user to fallback to browser implementation. + **/ + it("should inform user if no credential exists", async () => { + cipherService.getAllDecrypted.mockResolvedValue([]); + userInterfaceSession.informCredentialNotFound.mockResolvedValue(); + + try { + await authenticator.getAssertion(params, tab); + // eslint-disable-next-line no-empty + } catch {} + + expect(userInterfaceSession.informCredentialNotFound).toHaveBeenCalled(); + }); + + it("should inform user if credential exists but rpId does not match", async () => { + const cipher = await createCipherView({ type: CipherType.Login }); + cipher.login.fido2Credentials[0].credentialId = credentialId; + cipher.login.fido2Credentials[0].rpId = "mismatch-rpid"; + cipherService.getAllDecrypted.mockResolvedValue([cipher]); + userInterfaceSession.informCredentialNotFound.mockResolvedValue(); + + try { + await authenticator.getAssertion(params, tab); + // eslint-disable-next-line no-empty + } catch {} + + expect(userInterfaceSession.informCredentialNotFound).toHaveBeenCalled(); + }); + }); + + describe("vault is missing discoverable credential", () => { + let params: Fido2AuthenticatorGetAssertionParams; + + beforeEach(async () => { + params = await createParams({ + allowCredentialDescriptorList: [], + rpId: RpId, + }); + cipherService.getAllDecrypted.mockResolvedValue([]); + }); + + /** Spec: If credentialOptions is now empty, return an error code equivalent to "NotAllowedError" and terminate the operation. */ + it("should throw error", async () => { + const result = async () => await authenticator.getAssertion(params, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed); + }); + }); + + describe("vault contains credential", () => { + let credentialIds: string[]; + let ciphers: CipherView[]; + let params: Fido2AuthenticatorGetAssertionParams; + + beforeEach(async () => { + credentialIds = [Utils.newGuid(), Utils.newGuid()]; + ciphers = [ + await createCipherView( + { type: CipherType.Login }, + { credentialId: credentialIds[0], rpId: RpId, discoverable: false } + ), + await createCipherView( + { type: CipherType.Login }, + { credentialId: credentialIds[1], rpId: RpId, discoverable: true } + ), + ]; + params = await createParams({ + allowCredentialDescriptorList: credentialIds.map((credentialId) => ({ + id: guidToRawFormat(credentialId), + type: "public-key", + })), + rpId: RpId, + }); + cipherService.getAllDecrypted.mockResolvedValue(ciphers); + }); + + it("should ask for all credentials in list when `params` contains allowedCredentials list", async () => { + userInterfaceSession.pickCredential.mockResolvedValue({ + cipherId: ciphers[0].id, + userVerified: false, + }); + + await authenticator.getAssertion(params, tab); + + expect(userInterfaceSession.pickCredential).toHaveBeenCalledWith({ + cipherIds: ciphers.map((c) => c.id), + userVerification: false, + }); + }); + + it("should only ask for discoverable credentials matched by rpId when params does not contains allowedCredentials list", async () => { + params.allowCredentialDescriptorList = undefined; + const discoverableCiphers = ciphers.filter((c) => c.login.fido2Credentials[0].discoverable); + userInterfaceSession.pickCredential.mockResolvedValue({ + cipherId: discoverableCiphers[0].id, + userVerified: false, + }); + + await authenticator.getAssertion(params, tab); + + expect(userInterfaceSession.pickCredential).toHaveBeenCalledWith({ + cipherIds: [discoverableCiphers[0].id], + userVerification: false, + }); + }); + + for (const userVerification of [true, false]) { + /** Spec: Prompt the user to select a public key credential source selectedCredential from credentialOptions. */ + it(`should request confirmation from user when user verification is ${userVerification}`, async () => { + params.requireUserVerification = userVerification; + userInterfaceSession.pickCredential.mockResolvedValue({ + cipherId: ciphers[0].id, + userVerified: userVerification, + }); + + await authenticator.getAssertion(params, tab); + + expect(userInterfaceSession.pickCredential).toHaveBeenCalledWith({ + cipherIds: ciphers.map((c) => c.id), + userVerification, + }); + }); + } + + /** Spec: If the user does not consent, return an error code equivalent to "NotAllowedError" and terminate the operation. */ + it("should throw error", async () => { + userInterfaceSession.pickCredential.mockResolvedValue({ + cipherId: undefined, + userVerified: false, + }); + + const result = async () => await authenticator.getAssertion(params, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed); + }); + + it("should throw error if user verification fails and cipher requires reprompt", async () => { + ciphers[0].reprompt = CipherRepromptType.Password; + userInterfaceSession.pickCredential.mockResolvedValue({ + cipherId: ciphers[0].id, + userVerified: false, + }); + + const result = async () => await authenticator.getAssertion(params, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed); + }); + }); + + describe("assertion of credential", () => { + let keyPair: CryptoKeyPair; + let credentialIds: string[]; + let selectedCredentialId: string; + let ciphers: CipherView[]; + let fido2Credentials: Fido2CredentialView[]; + let params: Fido2AuthenticatorGetAssertionParams; + + const init = async () => { + keyPair = await createKeyPair(); + credentialIds = [Utils.newGuid(), Utils.newGuid()]; + const keyValue = Fido2Utils.bufferToString( + await crypto.subtle.exportKey("pkcs8", keyPair.privateKey) + ); + ciphers = credentialIds.map((id) => + createCipherView( + { type: CipherType.Login }, + { credentialId: id, rpId: RpId, counter: 9000, keyValue } + ) + ); + fido2Credentials = ciphers.map((c) => c.login.fido2Credentials[0]); + selectedCredentialId = credentialIds[0]; + params = await createParams({ + allowCredentialDescriptorList: credentialIds.map((credentialId) => ({ + id: guidToRawFormat(credentialId), + type: "public-key", + })), + rpId: RpId, + }); + cipherService.getAllDecrypted.mockResolvedValue(ciphers); + userInterfaceSession.pickCredential.mockResolvedValue({ + cipherId: ciphers[0].id, + userVerified: false, + }); + }; + beforeEach(init); + + /** Spec: Increment the credential associated signature counter */ + it("should increment counter", async () => { + const encrypted = Symbol(); + cipherService.encrypt.mockResolvedValue(encrypted as any); + + await authenticator.getAssertion(params, tab); + + expect(cipherService.updateWithServer).toHaveBeenCalledWith(encrypted); + + expect(cipherService.encrypt).toHaveBeenCalledWith( + expect.objectContaining({ + id: ciphers[0].id, + login: expect.objectContaining({ + fido2Credentials: [ + expect.objectContaining({ + counter: 9001, + }), + ], + }), + }) + ); + }); + + it("should return an assertion result", async () => { + const result = await authenticator.getAssertion(params, tab); + + const encAuthData = result.authenticatorData; + const rpIdHash = encAuthData.slice(0, 32); + const flags = encAuthData.slice(32, 33); + const counter = encAuthData.slice(33, 37); + + expect(result.selectedCredential.id).toEqual(guidToRawFormat(selectedCredentialId)); + expect(result.selectedCredential.userHandle).toEqual( + Fido2Utils.stringToBuffer(fido2Credentials[0].userHandle) + ); + expect(rpIdHash).toEqual( + new Uint8Array([ + 0x22, 0x6b, 0xb3, 0x92, 0x02, 0xff, 0xf9, 0x22, 0xdc, 0x74, 0x05, 0xcd, 0x28, 0xa8, + 0x34, 0x5a, 0xc4, 0xf2, 0x64, 0x51, 0xd7, 0x3d, 0x0b, 0x40, 0xef, 0xf3, 0x1d, 0xc1, + 0xd0, 0x5c, 0x3d, 0xc3, + ]) + ); + expect(flags).toEqual(new Uint8Array([0b00000001])); // UP = true + expect(counter).toEqual(new Uint8Array([0, 0, 0x23, 0x29])); // 9001 in hex + + // Verify signature + // TODO: Cannot verify signature because it has been converted into DER format + // const sigBase = new Uint8Array([ + // ...result.authenticatorData, + // ...Fido2Utils.bufferSourceToUint8Array(params.hash), + // ]); + // const isValidSignature = await crypto.subtle.verify( + // { name: "ECDSA", hash: { name: "SHA-256" } }, + // keyPair.publicKey, + // result.signature, + // sigBase + // ); + // expect(isValidSignature).toBe(true); + }); + + it("should always generate unique signatures even if the input is the same", async () => { + const signatures = new Set(); + + for (let i = 0; i < 10; ++i) { + await init(); // Reset inputs + const result = await authenticator.getAssertion(params, tab); + + const counter = result.authenticatorData.slice(33, 37); + expect(counter).toEqual(new Uint8Array([0, 0, 0x23, 0x29])); // double check that the counter doesn't change + + const signature = Fido2Utils.bufferToString(result.signature); + if (signatures.has(signature)) { + throw new Error("Found duplicate signature"); + } + signatures.add(signature); + } + }); + + /** Spec: If any error occurred while generating the assertion signature, return an error code equivalent to "UnknownError" and terminate the operation. */ + it("should throw unkown error if creation fails", async () => { + cipherService.updateWithServer.mockRejectedValue(new Error("Internal error")); + + const result = async () => await authenticator.getAssertion(params, tab); + + await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown); + }); + }); + + async function createParams( + params: Partial = {} + ): Promise { + return { + rpId: params.rpId ?? RpId, + hash: params.hash ?? (await createClientDataHash()), + allowCredentialDescriptorList: params.allowCredentialDescriptorList ?? [], + requireUserVerification: params.requireUserVerification ?? false, + extensions: params.extensions ?? {}, + fallbackSupported: params.fallbackSupported ?? false, + }; + } + + type InvalidParams = Awaited>; + async function createInvalidParams() { + const emptyRpId = await createParams(); + emptyRpId.rpId = undefined as any; + return { + emptyRpId, + invalidUv: await createParams({ + requireUserVerification: "invalid-value" as any, + }), + }; + } + }); +}); + +function createCipherView( + data: Partial> = {}, + fido2Credential: Partial = {} +): CipherView { + const cipher = new CipherView(); + cipher.id = data.id ?? Utils.newGuid(); + cipher.type = CipherType.Login; + cipher.localData = {}; + + const fido2CredentialView = new Fido2CredentialView(); + fido2CredentialView.credentialId = fido2Credential.credentialId ?? Utils.newGuid(); + fido2CredentialView.rpId = fido2Credential.rpId ?? RpId; + fido2CredentialView.counter = fido2Credential.counter ?? 0; + fido2CredentialView.userHandle = + fido2Credential.userHandle ?? Fido2Utils.bufferToString(randomBytes(16)); + fido2CredentialView.keyAlgorithm = fido2Credential.keyAlgorithm ?? "ECDSA"; + fido2CredentialView.keyCurve = fido2Credential.keyCurve ?? "P-256"; + fido2CredentialView.discoverable = fido2Credential.discoverable ?? true; + fido2CredentialView.keyValue = + fido2CredentialView.keyValue ?? + "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgTC-7XDZipXbaVBlnkjlBgO16ZmqBZWejK2iYo6lV0dehRANCAASOcM2WduNq1DriRYN7ZekvZz-bRhA-qNT4v0fbp5suUFJyWmgOQ0bybZcLXHaerK5Ep1JiSrQcewtQNgLtry7f"; + + cipher.login = new LoginView(); + cipher.login.fido2Credentials = [fido2CredentialView]; + + return cipher; +} + +async function createClientDataHash() { + const encoder = new TextEncoder(); + const clientData = encoder.encode( + JSON.stringify({ + type: "webauthn.create", + challenge: Fido2Utils.bufferToString(randomBytes(16)), + origin: RpId, + crossOrigin: false, + }) + ); + return await crypto.subtle.digest({ name: "SHA-256" }, clientData); +} + +/** This is a fake function that always returns the same byte sequence */ +function randomBytes(length: number) { + return new Uint8Array(Array.from({ length }, (_, k) => k % 255)); +} + +async function createKeyPair() { + return await crypto.subtle.generateKey( + { + name: "ECDSA", + namedCurve: "P-256", + }, + true, + ["sign", "verify"] + ); +} diff --git a/libs/common/src/vault/services/fido2/fido2-authenticator.service.ts b/libs/common/src/vault/services/fido2/fido2-authenticator.service.ts new file mode 100644 index 00000000000..e009d8b18ca --- /dev/null +++ b/libs/common/src/vault/services/fido2/fido2-authenticator.service.ts @@ -0,0 +1,545 @@ +import { LogService } from "../../../platform/abstractions/log.service"; +import { Utils } from "../../../platform/misc/utils"; +import { CipherService } from "../../abstractions/cipher.service"; +import { + Fido2AlgorithmIdentifier, + Fido2AutenticatorError, + Fido2AutenticatorErrorCode, + Fido2AuthenticatorGetAssertionParams, + Fido2AuthenticatorGetAssertionResult, + Fido2AuthenticatorMakeCredentialResult, + Fido2AuthenticatorMakeCredentialsParams, + Fido2AuthenticatorService as Fido2AuthenticatorServiceAbstraction, + PublicKeyCredentialDescriptor, +} from "../../abstractions/fido2/fido2-authenticator.service.abstraction"; +import { Fido2UserInterfaceService } from "../../abstractions/fido2/fido2-user-interface.service.abstraction"; +import { SyncService } from "../../abstractions/sync/sync.service.abstraction"; +import { CipherRepromptType } from "../../enums/cipher-reprompt-type"; +import { CipherType } from "../../enums/cipher-type"; +import { CipherView } from "../../models/view/cipher.view"; +import { Fido2CredentialView } from "../../models/view/fido2-credential.view"; + +import { CBOR } from "./cbor"; +import { joseToDer } from "./ecdsa-utils"; +import { Fido2Utils } from "./fido2-utils"; +import { guidToRawFormat, guidToStandardFormat } from "./guid-utils"; + +// AAGUID: 6e8248d5-b479-40db-a3d8-11116f7e8349 +export const AAGUID = new Uint8Array([ + 0xd5, 0x48, 0x82, 0x6e, 0x79, 0xb4, 0xdb, 0x40, 0xa3, 0xd8, 0x11, 0x11, 0x6f, 0x7e, 0x83, 0x49, +]); + +const KeyUsages: KeyUsage[] = ["sign"]; + +/** + * Bitwarden implementation of the WebAuthn Authenticator Model as described by W3C + * https://www.w3.org/TR/webauthn-3/#sctn-authenticator-model + * + * It is highly recommended that the W3C specification is used a reference when reading this code. + */ +export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstraction { + constructor( + private cipherService: CipherService, + private userInterface: Fido2UserInterfaceService, + private syncService: SyncService, + private logService?: LogService + ) {} + + async makeCredential( + params: Fido2AuthenticatorMakeCredentialsParams, + tab: chrome.tabs.Tab, + abortController?: AbortController + ): Promise { + const userInterfaceSession = await this.userInterface.newSession( + params.fallbackSupported, + tab, + abortController + ); + + try { + if (params.credTypesAndPubKeyAlgs.every((p) => p.alg !== Fido2AlgorithmIdentifier.ES256)) { + const requestedAlgorithms = params.credTypesAndPubKeyAlgs.map((p) => p.alg).join(", "); + this.logService?.warning( + `[Fido2Authenticator] No compatible algorithms found, RP requested: ${requestedAlgorithms}` + ); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotSupported); + } + + if ( + params.requireResidentKey != undefined && + typeof params.requireResidentKey !== "boolean" + ) { + this.logService?.error( + `[Fido2Authenticator] Invalid 'requireResidentKey' value: ${String( + params.requireResidentKey + )}` + ); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown); + } + + if ( + params.requireUserVerification != undefined && + typeof params.requireUserVerification !== "boolean" + ) { + this.logService?.error( + `[Fido2Authenticator] Invalid 'requireUserVerification' value: ${String( + params.requireUserVerification + )}` + ); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown); + } + + await userInterfaceSession.ensureUnlockedVault(); + await this.syncService.fullSync(false); + + const existingCipherIds = await this.findExcludedCredentials( + params.excludeCredentialDescriptorList + ); + if (existingCipherIds.length > 0) { + this.logService?.info( + `[Fido2Authenticator] Aborting due to excluded credential found in vault.` + ); + await userInterfaceSession.informExcludedCredential(existingCipherIds); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed); + } + + let cipher: CipherView; + let fido2Credential: Fido2CredentialView; + let keyPair: CryptoKeyPair; + let userVerified = false; + let credentialId: string; + const response = await userInterfaceSession.confirmNewCredential({ + credentialName: params.rpEntity.name, + userName: params.userEntity.displayName, + userVerification: params.requireUserVerification, + }); + const cipherId = response.cipherId; + userVerified = response.userVerified; + + if (cipherId === undefined) { + this.logService?.warning( + `[Fido2Authenticator] Aborting because user confirmation was not recieved.` + ); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed); + } + + try { + keyPair = await createKeyPair(); + + const encrypted = await this.cipherService.get(cipherId); + cipher = await encrypted.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(encrypted) + ); + + if ( + !userVerified && + (params.requireUserVerification || cipher.reprompt !== CipherRepromptType.None) + ) { + this.logService?.warning( + `[Fido2Authenticator] Aborting because user verification was unsuccessful.` + ); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed); + } + + fido2Credential = await createKeyView(params, keyPair.privateKey); + cipher.login.fido2Credentials = [fido2Credential]; + const reencrypted = await this.cipherService.encrypt(cipher); + await this.cipherService.updateWithServer(reencrypted); + credentialId = fido2Credential.credentialId; + } catch (error) { + this.logService?.error( + `[Fido2Authenticator] Aborting because of unknown error when creating credential: ${error}` + ); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown); + } + + const authData = await generateAuthData({ + rpId: params.rpEntity.id, + credentialId: guidToRawFormat(credentialId), + counter: fido2Credential.counter, + userPresence: true, + userVerification: userVerified, + keyPair, + }); + const attestationObject = new Uint8Array( + CBOR.encode({ + fmt: "none", + attStmt: {}, + authData, + }) + ); + + return { + credentialId: guidToRawFormat(credentialId), + attestationObject, + authData, + publicKeyAlgorithm: -7, + }; + } finally { + userInterfaceSession.close(); + } + } + + async getAssertion( + params: Fido2AuthenticatorGetAssertionParams, + tab: chrome.tabs.Tab, + abortController?: AbortController + ): Promise { + const userInterfaceSession = await this.userInterface.newSession( + params.fallbackSupported, + tab, + abortController + ); + try { + if ( + params.requireUserVerification != undefined && + typeof params.requireUserVerification !== "boolean" + ) { + this.logService?.error( + `[Fido2Authenticator] Invalid 'requireUserVerification' value: ${String( + params.requireUserVerification + )}` + ); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown); + } + + let cipherOptions: CipherView[]; + + await userInterfaceSession.ensureUnlockedVault(); + await this.syncService.fullSync(false); + + if (params.allowCredentialDescriptorList?.length > 0) { + cipherOptions = await this.findCredentialsById( + params.allowCredentialDescriptorList, + params.rpId + ); + } else { + cipherOptions = await this.findCredentialsByRp(params.rpId); + } + + if (cipherOptions.length === 0) { + this.logService?.info( + `[Fido2Authenticator] Aborting because no matching credentials were found in the vault.` + ); + await userInterfaceSession.informCredentialNotFound(); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed); + } + + const response = await userInterfaceSession.pickCredential({ + cipherIds: cipherOptions.map((cipher) => cipher.id), + userVerification: params.requireUserVerification, + }); + const selectedCipherId = response.cipherId; + const userVerified = response.userVerified; + const selectedCipher = cipherOptions.find((c) => c.id === selectedCipherId); + + if (selectedCipher === undefined) { + this.logService?.error( + `[Fido2Authenticator] Aborting because the selected credential could not be found.` + ); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed); + } + + if ( + !userVerified && + (params.requireUserVerification || selectedCipher.reprompt !== CipherRepromptType.None) + ) { + this.logService?.warning( + `[Fido2Authenticator] Aborting because user verification was unsuccessful.` + ); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed); + } + + try { + const selectedFido2Credential = selectedCipher.login.fido2Credentials[0]; + const selectedCredentialId = selectedFido2Credential.credentialId; + + ++selectedFido2Credential.counter; + + selectedCipher.localData = { + ...selectedCipher.localData, + lastUsedDate: new Date().getTime(), + }; + const encrypted = await this.cipherService.encrypt(selectedCipher); + await this.cipherService.updateWithServer(encrypted); + + const authenticatorData = await generateAuthData({ + rpId: selectedFido2Credential.rpId, + credentialId: guidToRawFormat(selectedCredentialId), + counter: selectedFido2Credential.counter, + userPresence: true, + userVerification: userVerified, + }); + + const signature = await generateSignature({ + authData: authenticatorData, + clientDataHash: params.hash, + privateKey: await getPrivateKeyFromFido2Credential(selectedFido2Credential), + }); + + return { + authenticatorData, + selectedCredential: { + id: guidToRawFormat(selectedCredentialId), + userHandle: Fido2Utils.stringToBuffer(selectedFido2Credential.userHandle), + }, + signature, + }; + } catch (error) { + this.logService?.error( + `[Fido2Authenticator] Aborting because of unknown error when asserting credential: ${error}` + ); + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown); + } + } finally { + userInterfaceSession.close(); + } + } + + /** Finds existing crendetials and returns the `cipherId` for each one */ + private async findExcludedCredentials( + credentials: PublicKeyCredentialDescriptor[] + ): Promise { + const ids: string[] = []; + + for (const credential of credentials) { + try { + ids.push(guidToStandardFormat(credential.id)); + // eslint-disable-next-line no-empty + } catch {} + } + + if (ids.length === 0) { + return []; + } + + const ciphers = await this.cipherService.getAllDecrypted(); + return ciphers + .filter( + (cipher) => + !cipher.isDeleted && + cipher.organizationId == undefined && + cipher.type === CipherType.Login && + cipher.login.hasFido2Credentials && + ids.includes(cipher.login.fido2Credentials[0].credentialId) + ) + .map((cipher) => cipher.id); + } + + private async findCredentialsById( + credentials: PublicKeyCredentialDescriptor[], + rpId: string + ): Promise { + const ids: string[] = []; + + for (const credential of credentials) { + try { + ids.push(guidToStandardFormat(credential.id)); + // eslint-disable-next-line no-empty + } catch {} + } + + if (ids.length === 0) { + return []; + } + + const ciphers = await this.cipherService.getAllDecrypted(); + return ciphers.filter( + (cipher) => + !cipher.isDeleted && + cipher.type === CipherType.Login && + cipher.login.hasFido2Credentials && + cipher.login.fido2Credentials[0].rpId === rpId && + ids.includes(cipher.login.fido2Credentials[0].credentialId) + ); + } + + private async findCredentialsByRp(rpId: string): Promise { + const ciphers = await this.cipherService.getAllDecrypted(); + return ciphers.filter( + (cipher) => + !cipher.isDeleted && + cipher.type === CipherType.Login && + cipher.login.hasFido2Credentials && + cipher.login.fido2Credentials[0].rpId === rpId && + cipher.login.fido2Credentials[0].discoverable + ); + } +} + +async function createKeyPair() { + return await crypto.subtle.generateKey( + { + name: "ECDSA", + namedCurve: "P-256", + }, + true, + KeyUsages + ); +} + +async function createKeyView( + params: Fido2AuthenticatorMakeCredentialsParams, + keyValue: CryptoKey +): Promise { + if (keyValue.algorithm.name !== "ECDSA" && (keyValue.algorithm as any).namedCurve !== "P-256") { + throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown); + } + + const pkcs8Key = await crypto.subtle.exportKey("pkcs8", keyValue); + const fido2Credential = new Fido2CredentialView(); + fido2Credential.credentialId = Utils.newGuid(); + fido2Credential.keyType = "public-key"; + fido2Credential.keyAlgorithm = "ECDSA"; + fido2Credential.keyCurve = "P-256"; + fido2Credential.keyValue = Fido2Utils.bufferToString(pkcs8Key); + fido2Credential.rpId = params.rpEntity.id; + fido2Credential.userHandle = Fido2Utils.bufferToString(params.userEntity.id); + fido2Credential.counter = 0; + fido2Credential.rpName = params.rpEntity.name; + fido2Credential.userDisplayName = params.userEntity.displayName; + fido2Credential.discoverable = params.requireResidentKey; + fido2Credential.creationDate = new Date(); + + return fido2Credential; +} + +async function getPrivateKeyFromFido2Credential( + fido2Credential: Fido2CredentialView +): Promise { + const keyBuffer = Fido2Utils.stringToBuffer(fido2Credential.keyValue); + return await crypto.subtle.importKey( + "pkcs8", + keyBuffer, + { + name: fido2Credential.keyAlgorithm, + namedCurve: fido2Credential.keyCurve, + } as EcKeyImportParams, + true, + KeyUsages + ); +} + +interface AuthDataParams { + rpId: string; + credentialId: BufferSource; + userPresence: boolean; + userVerification: boolean; + counter: number; + keyPair?: CryptoKeyPair; +} + +async function generateAuthData(params: AuthDataParams) { + const authData: Array = []; + + const rpIdHash = new Uint8Array( + await crypto.subtle.digest({ name: "SHA-256" }, Utils.fromByteStringToArray(params.rpId)) + ); + authData.push(...rpIdHash); + + const flags = authDataFlags({ + extensionData: false, + attestationData: params.keyPair != undefined, + userVerification: params.userVerification, + userPresence: params.userPresence, + }); + authData.push(flags); + + // add 4 bytes of counter - we use time in epoch seconds as monotonic counter + // TODO: Consider changing this to a cryptographically safe random number + const counter = params.counter; + authData.push( + ((counter & 0xff000000) >> 24) & 0xff, + ((counter & 0x00ff0000) >> 16) & 0xff, + ((counter & 0x0000ff00) >> 8) & 0xff, + counter & 0x000000ff + ); + + if (params.keyPair) { + // attestedCredentialData + const attestedCredentialData: Array = []; + + attestedCredentialData.push(...AAGUID); + + // credentialIdLength (2 bytes) and credential Id + const rawId = Fido2Utils.bufferSourceToUint8Array(params.credentialId); + const credentialIdLength = [(rawId.length - (rawId.length & 0xff)) / 256, rawId.length & 0xff]; + attestedCredentialData.push(...credentialIdLength); + attestedCredentialData.push(...rawId); + + const publicKeyJwk = await crypto.subtle.exportKey("jwk", params.keyPair.publicKey); + // COSE format of the EC256 key + const keyX = Utils.fromUrlB64ToArray(publicKeyJwk.x); + const keyY = Utils.fromUrlB64ToArray(publicKeyJwk.y); + + // Can't get `cbor-redux` to encode in CTAP2 canonical CBOR. So we do it manually: + const coseBytes = new Uint8Array(77); + coseBytes.set([0xa5, 0x01, 0x02, 0x03, 0x26, 0x20, 0x01, 0x21, 0x58, 0x20], 0); + coseBytes.set(keyX, 10); + coseBytes.set([0x22, 0x58, 0x20], 10 + 32); + coseBytes.set(keyY, 10 + 32 + 3); + + // credential public key - convert to array from CBOR encoded COSE key + attestedCredentialData.push(...coseBytes); + + authData.push(...attestedCredentialData); + } + + return new Uint8Array(authData); +} + +interface SignatureParams { + authData: Uint8Array; + clientDataHash: BufferSource; + privateKey: CryptoKey; +} + +async function generateSignature(params: SignatureParams) { + const sigBase = new Uint8Array([ + ...params.authData, + ...Fido2Utils.bufferSourceToUint8Array(params.clientDataHash), + ]); + const p1336_signature = new Uint8Array( + await crypto.subtle.sign( + { + name: "ECDSA", + hash: { name: "SHA-256" }, + }, + params.privateKey, + sigBase + ) + ); + + const asn1Der_signature = joseToDer(p1336_signature, "ES256"); + + return asn1Der_signature; +} + +interface Flags { + extensionData: boolean; + attestationData: boolean; + userVerification: boolean; + userPresence: boolean; +} + +function authDataFlags(options: Flags): number { + let flags = 0; + + if (options.extensionData) { + flags |= 0b1000000; + } + + if (options.attestationData) { + flags |= 0b01000000; + } + + if (options.userVerification) { + flags |= 0b00000100; + } + + if (options.userPresence) { + flags |= 0b00000001; + } + + return flags; +} diff --git a/libs/common/src/vault/services/fido2/fido2-client.service.spec.ts b/libs/common/src/vault/services/fido2/fido2-client.service.spec.ts new file mode 100644 index 00000000000..a8b2a071c14 --- /dev/null +++ b/libs/common/src/vault/services/fido2/fido2-client.service.spec.ts @@ -0,0 +1,463 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { AuthService } from "../../../auth/abstractions/auth.service"; +import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; +import { ConfigServiceAbstraction } from "../../../platform/abstractions/config/config.service.abstraction"; +import { Utils } from "../../../platform/misc/utils"; +import { + Fido2AutenticatorError, + Fido2AutenticatorErrorCode, + Fido2AuthenticatorGetAssertionResult, + Fido2AuthenticatorMakeCredentialResult, +} from "../../abstractions/fido2/fido2-authenticator.service.abstraction"; +import { + AssertCredentialParams, + CreateCredentialParams, + FallbackRequestedError, +} from "../../abstractions/fido2/fido2-client.service.abstraction"; + +import { Fido2AuthenticatorService } from "./fido2-authenticator.service"; +import { Fido2ClientService } from "./fido2-client.service"; +import { Fido2Utils } from "./fido2-utils"; +import { guidToRawFormat } from "./guid-utils"; + +const RpId = "bitwarden.com"; + +describe("FidoAuthenticatorService", () => { + let authenticator!: MockProxy; + let configService!: MockProxy; + let authService!: MockProxy; + let client!: Fido2ClientService; + let tab!: chrome.tabs.Tab; + + beforeEach(async () => { + authenticator = mock(); + configService = mock(); + authService = mock(); + + client = new Fido2ClientService(authenticator, configService, authService); + configService.getFeatureFlag.mockResolvedValue(true); + tab = { id: 123, windowId: 456 } as chrome.tabs.Tab; + }); + + describe("createCredential", () => { + describe("input parameters validation", () => { + // Spec: If sameOriginWithAncestors is false, return a "NotAllowedError" DOMException. + it("should throw error if sameOriginWithAncestors is false", async () => { + const params = createParams({ sameOriginWithAncestors: false }); + + const result = async () => await client.createCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "NotAllowedError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + // Spec: If the length of options.user.id is not between 1 and 64 bytes (inclusive) then return a TypeError. + it("should throw error if user.id is too small", async () => { + const params = createParams({ user: { id: "", displayName: "name" } }); + + const result = async () => await client.createCredential(params, tab); + + await expect(result).rejects.toBeInstanceOf(TypeError); + }); + + // Spec: If the length of options.user.id is not between 1 and 64 bytes (inclusive) then return a TypeError. + it("should throw error if user.id is too large", async () => { + const params = createParams({ + user: { + id: "YWJzb2x1dGVseS13YXktd2F5LXRvby1sYXJnZS1iYXNlNjQtZW5jb2RlZC11c2VyLWlkLWJpbmFyeS1zZXF1ZW5jZQ", + displayName: "name", + }, + }); + + const result = async () => await client.createCredential(params, tab); + + await expect(result).rejects.toBeInstanceOf(TypeError); + }); + + // Spec: If callerOrigin is an opaque origin, return a DOMException whose name is "NotAllowedError", and terminate this algorithm. + // Not sure how to check this, or if it matters. + it.todo("should throw error if origin is an opaque origin"); + + // Spec: Let effectiveDomain be the callerOrigin’s effective domain. If effective domain is not a valid domain, then return a DOMException whose name is "SecurityError" and terminate this algorithm. + it("should throw error if origin is not a valid domain name", async () => { + const params = createParams({ + origin: "invalid-domain-name", + }); + + const result = async () => await client.createCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "SecurityError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + // Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, return a DOMException whose name is "SecurityError", and terminate this algorithm. + it("should throw error if rp.id is not valid for this origin", async () => { + const params = createParams({ + origin: "https://passwordless.dev", + rp: { id: "bitwarden.com", name: "Bitwraden" }, + }); + + const result = async () => await client.createCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "SecurityError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + it("should throw error if origin is not an https domain", async () => { + const params = createParams({ + origin: "http://passwordless.dev", + rp: { id: "bitwarden.com", name: "Bitwraden" }, + }); + + const result = async () => await client.createCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "SecurityError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + // Spec: If credTypesAndPubKeyAlgs is empty, return a DOMException whose name is "NotSupportedError", and terminate this algorithm. + it("should throw error if no support key algorithms were found", async () => { + const params = createParams({ + pubKeyCredParams: [ + { alg: -9001, type: "public-key" }, + { alg: -7, type: "not-supported" as any }, + ], + }); + + const result = async () => await client.createCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "NotSupportedError" }); + await rejects.toBeInstanceOf(DOMException); + }); + }); + + describe("aborting", () => { + // Spec: If the options.signal is present and its aborted flag is set to true, return a DOMException whose name is "AbortError" and terminate this algorithm. + it("should throw error if aborting using abort controller", async () => { + const params = createParams({}); + const abortController = new AbortController(); + abortController.abort(); + + const result = async () => await client.createCredential(params, tab, abortController); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "AbortError" }); + await rejects.toBeInstanceOf(DOMException); + }); + }); + + describe("creating a new credential", () => { + it("should call authenticator.makeCredential", async () => { + const params = createParams({ + authenticatorSelection: { residentKey: "required", userVerification: "required" }, + }); + authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult()); + + await client.createCredential(params, tab); + + expect(authenticator.makeCredential).toHaveBeenCalledWith( + expect.objectContaining({ + requireResidentKey: true, + requireUserVerification: true, + rpEntity: expect.objectContaining({ + id: RpId, + }), + userEntity: expect.objectContaining({ + displayName: params.user.displayName, + }), + }), + tab, + expect.anything() + ); + }); + + // Spec: If any authenticator returns an error status equivalent to "InvalidStateError", Return a DOMException whose name is "InvalidStateError" and terminate this algorithm. + it("should throw error if authenticator throws InvalidState", async () => { + const params = createParams(); + authenticator.makeCredential.mockRejectedValue( + new Fido2AutenticatorError(Fido2AutenticatorErrorCode.InvalidState) + ); + + const result = async () => await client.createCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "InvalidStateError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + // This keeps sensetive information form leaking + it("should throw NotAllowedError if authenticator throws unknown error", async () => { + const params = createParams(); + authenticator.makeCredential.mockRejectedValue(new Error("unknown error")); + + const result = async () => await client.createCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "NotAllowedError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + it("should throw FallbackRequestedError if feature flag is not enabled", async () => { + const params = createParams(); + configService.getFeatureFlag.mockResolvedValue(false); + + const result = async () => await client.createCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toThrow(FallbackRequestedError); + }); + + it("should throw FallbackRequestedError if user is logged out", async () => { + const params = createParams(); + authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.LoggedOut); + + const result = async () => await client.createCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toThrow(FallbackRequestedError); + }); + }); + + function createParams(params: Partial = {}): CreateCredentialParams { + return { + origin: params.origin ?? "https://bitwarden.com", + sameOriginWithAncestors: params.sameOriginWithAncestors ?? true, + attestation: params.attestation, + authenticatorSelection: params.authenticatorSelection, + challenge: params.challenge ?? "MzItYnl0ZXMtYmFzZTY0LWVuY29kZS1jaGFsbGVuZ2U", + excludeCredentials: params.excludeCredentials, + extensions: params.extensions, + pubKeyCredParams: params.pubKeyCredParams ?? [ + { + alg: -7, + type: "public-key", + }, + ], + rp: params.rp ?? { + id: RpId, + name: "Bitwarden", + }, + user: params.user ?? { + id: "YmFzZTY0LWVuY29kZWQtdXNlci1pZA", + displayName: "User Name", + }, + fallbackSupported: params.fallbackSupported ?? false, + timeout: params.timeout, + }; + } + + function createAuthenticatorMakeResult(): Fido2AuthenticatorMakeCredentialResult { + return { + credentialId: guidToRawFormat(Utils.newGuid()), + attestationObject: randomBytes(128), + authData: randomBytes(64), + publicKeyAlgorithm: -7, + }; + } + }); + + describe("assertCredential", () => { + describe("invalid params", () => { + // Spec: If callerOrigin is an opaque origin, return a DOMException whose name is "NotAllowedError", and terminate this algorithm. + // Not sure how to check this, or if it matters. + it.todo("should throw error if origin is an opaque origin"); + + // Spec: Let effectiveDomain be the callerOrigin’s effective domain. If effective domain is not a valid domain, then return a DOMException whose name is "SecurityError" and terminate this algorithm. + it("should throw error if origin is not a valid domain name", async () => { + const params = createParams({ + origin: "invalid-domain-name", + }); + + const result = async () => await client.assertCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "SecurityError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + // Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, return a DOMException whose name is "SecurityError", and terminate this algorithm. + it("should throw error if rp.id is not valid for this origin", async () => { + const params = createParams({ + origin: "https://passwordless.dev", + rpId: "bitwarden.com", + }); + + const result = async () => await client.assertCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "SecurityError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + it("should throw error if origin is not an http domain", async () => { + const params = createParams({ + origin: "http://passwordless.dev", + rpId: "bitwarden.com", + }); + + const result = async () => await client.assertCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "SecurityError" }); + await rejects.toBeInstanceOf(DOMException); + }); + }); + + describe("aborting", () => { + // Spec: If the options.signal is present and its aborted flag is set to true, return a DOMException whose name is "AbortError" and terminate this algorithm. + it("should throw error if aborting using abort controller", async () => { + const params = createParams({}); + const abortController = new AbortController(); + abortController.abort(); + + const result = async () => await client.assertCredential(params, tab, abortController); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "AbortError" }); + await rejects.toBeInstanceOf(DOMException); + }); + }); + + describe("assert credential", () => { + // Spec: If any authenticator returns an error status equivalent to "InvalidStateError", Return a DOMException whose name is "InvalidStateError" and terminate this algorithm. + it("should throw error if authenticator throws InvalidState", async () => { + const params = createParams(); + authenticator.getAssertion.mockRejectedValue( + new Fido2AutenticatorError(Fido2AutenticatorErrorCode.InvalidState) + ); + + const result = async () => await client.assertCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "InvalidStateError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + // This keeps sensetive information form leaking + it("should throw NotAllowedError if authenticator throws unknown error", async () => { + const params = createParams(); + authenticator.getAssertion.mockRejectedValue(new Error("unknown error")); + + const result = async () => await client.assertCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "NotAllowedError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + it("should throw FallbackRequestedError if feature flag is not enabled", async () => { + const params = createParams(); + configService.getFeatureFlag.mockResolvedValue(false); + + const result = async () => await client.assertCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toThrow(FallbackRequestedError); + }); + + it("should throw FallbackRequestedError if user is logged out", async () => { + const params = createParams(); + authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.LoggedOut); + + const result = async () => await client.assertCredential(params, tab); + + const rejects = expect(result).rejects; + await rejects.toThrow(FallbackRequestedError); + }); + }); + + describe("assert non-discoverable credential", () => { + it("should call authenticator.assertCredential", async () => { + const allowedCredentialIds = [ + Fido2Utils.bufferToString(guidToRawFormat(Utils.newGuid())), + Fido2Utils.bufferToString(guidToRawFormat(Utils.newGuid())), + Fido2Utils.bufferToString(Utils.fromByteStringToArray("not-a-guid")), + ]; + const params = createParams({ + userVerification: "required", + allowedCredentialIds, + }); + authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult()); + + await client.assertCredential(params, tab); + + expect(authenticator.getAssertion).toHaveBeenCalledWith( + expect.objectContaining({ + requireUserVerification: true, + rpId: RpId, + allowCredentialDescriptorList: [ + expect.objectContaining({ + id: Fido2Utils.stringToBuffer(allowedCredentialIds[0]), + }), + expect.objectContaining({ + id: Fido2Utils.stringToBuffer(allowedCredentialIds[1]), + }), + expect.objectContaining({ + id: Fido2Utils.stringToBuffer(allowedCredentialIds[2]), + }), + ], + }), + tab, + expect.anything() + ); + }); + }); + + describe("assert discoverable credential", () => { + it("should call authenticator.assertCredential", async () => { + const params = createParams({ + userVerification: "required", + allowedCredentialIds: [], + }); + authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult()); + + await client.assertCredential(params, tab); + + expect(authenticator.getAssertion).toHaveBeenCalledWith( + expect.objectContaining({ + requireUserVerification: true, + rpId: RpId, + allowCredentialDescriptorList: [], + }), + tab, + expect.anything() + ); + }); + }); + + function createParams(params: Partial = {}): AssertCredentialParams { + return { + allowedCredentialIds: params.allowedCredentialIds ?? [], + challenge: params.challenge ?? Fido2Utils.bufferToString(randomBytes(16)), + origin: params.origin ?? "https://bitwarden.com", + rpId: params.rpId ?? RpId, + timeout: params.timeout, + userVerification: params.userVerification, + sameOriginWithAncestors: true, + fallbackSupported: params.fallbackSupported ?? false, + }; + } + + function createAuthenticatorAssertResult(): Fido2AuthenticatorGetAssertionResult { + return { + selectedCredential: { + id: randomBytes(32), + userHandle: randomBytes(32), + }, + authenticatorData: randomBytes(64), + signature: randomBytes(64), + }; + } + }); +}); + +/** This is a fake function that always returns the same byte sequence */ +function randomBytes(length: number) { + return new Uint8Array(Array.from({ length }, (_, k) => k % 255)); +} diff --git a/libs/common/src/vault/services/fido2/fido2-client.service.ts b/libs/common/src/vault/services/fido2/fido2-client.service.ts new file mode 100644 index 00000000000..16049fd08a6 --- /dev/null +++ b/libs/common/src/vault/services/fido2/fido2-client.service.ts @@ -0,0 +1,409 @@ +import { parse } from "tldts"; + +import { AuthService } from "../../../auth/abstractions/auth.service"; +import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; +import { FeatureFlag } from "../../../enums/feature-flag.enum"; +import { ConfigServiceAbstraction } from "../../../platform/abstractions/config/config.service.abstraction"; +import { LogService } from "../../../platform/abstractions/log.service"; +import { Utils } from "../../../platform/misc/utils"; +import { + Fido2AutenticatorError, + Fido2AutenticatorErrorCode, + Fido2AuthenticatorGetAssertionParams, + Fido2AuthenticatorMakeCredentialsParams, + Fido2AuthenticatorService, + PublicKeyCredentialDescriptor, +} from "../../abstractions/fido2/fido2-authenticator.service.abstraction"; +import { + AssertCredentialParams, + AssertCredentialResult, + CreateCredentialParams, + CreateCredentialResult, + FallbackRequestedError, + Fido2ClientService as Fido2ClientServiceAbstraction, + PublicKeyCredentialParam, + UserRequestedFallbackAbortReason, + UserVerification, +} from "../../abstractions/fido2/fido2-client.service.abstraction"; + +import { isValidRpId } from "./domain-utils"; +import { Fido2Utils } from "./fido2-utils"; + +/** + * Bitwarden implementation of the Web Authentication API as described by W3C + * https://www.w3.org/TR/webauthn-3/#sctn-api + * + * It is highly recommended that the W3C specification is used a reference when reading this code. + */ +export class Fido2ClientService implements Fido2ClientServiceAbstraction { + constructor( + private authenticator: Fido2AuthenticatorService, + private configService: ConfigServiceAbstraction, + private authService: AuthService, + private logService?: LogService + ) {} + + async isFido2FeatureEnabled(): Promise { + return await this.configService.getFeatureFlag(FeatureFlag.Fido2VaultCredentials); + } + + async createCredential( + params: CreateCredentialParams, + tab: chrome.tabs.Tab, + abortController = new AbortController() + ): Promise { + const enableFido2VaultCredentials = await this.isFido2FeatureEnabled(); + + if (!enableFido2VaultCredentials) { + this.logService?.warning(`[Fido2Client] Fido2VaultCredential is not enabled`); + throw new FallbackRequestedError(); + } + + const authStatus = await this.authService.getAuthStatus(); + + if (authStatus === AuthenticationStatus.LoggedOut) { + this.logService?.warning(`[Fido2Client] Fido2VaultCredential is not enabled`); + throw new FallbackRequestedError(); + } + + if (!params.sameOriginWithAncestors) { + this.logService?.warning( + `[Fido2Client] Invalid 'sameOriginWithAncestors' value: ${params.sameOriginWithAncestors}` + ); + throw new DOMException("Invalid 'sameOriginWithAncestors' value", "NotAllowedError"); + } + + const userId = Fido2Utils.stringToBuffer(params.user.id); + if (userId.length < 1 || userId.length > 64) { + this.logService?.warning( + `[Fido2Client] Invalid 'user.id' length: ${params.user.id} (${userId.length})` + ); + throw new TypeError("Invalid 'user.id' length"); + } + + const parsedOrigin = parse(params.origin, { allowPrivateDomains: true }); + params.rp.id = params.rp.id ?? parsedOrigin.hostname; + + if (parsedOrigin.hostname == undefined || !params.origin.startsWith("https://")) { + this.logService?.warning(`[Fido2Client] Invalid https origin: ${params.origin}`); + throw new DOMException("'origin' is not a valid https origin", "SecurityError"); + } + + if (!isValidRpId(params.rp.id, params.origin)) { + this.logService?.warning( + `[Fido2Client] 'rp.id' cannot be used with the current origin: rp.id = ${params.rp.id}; origin = ${params.origin}` + ); + throw new DOMException("'rp.id' cannot be used with the current origin", "SecurityError"); + } + + let credTypesAndPubKeyAlgs: PublicKeyCredentialParam[]; + if (params.pubKeyCredParams?.length > 0) { + // Filter out all unsupported algorithms + credTypesAndPubKeyAlgs = params.pubKeyCredParams.filter( + (kp) => kp.alg === -7 && kp.type === "public-key" + ); + } else { + // Assign default algorithms + credTypesAndPubKeyAlgs = [ + { alg: -7, type: "public-key" }, + { alg: -257, type: "public-key" }, + ]; + } + + if (credTypesAndPubKeyAlgs.length === 0) { + const requestedAlgorithms = credTypesAndPubKeyAlgs.map((p) => p.alg).join(", "); + this.logService?.warning( + `[Fido2Client] No compatible algorithms found, RP requested: ${requestedAlgorithms}` + ); + throw new DOMException("No supported key algorithms were found", "NotSupportedError"); + } + + const collectedClientData = { + type: "webauthn.create", + challenge: params.challenge, + origin: params.origin, + crossOrigin: !params.sameOriginWithAncestors, + // tokenBinding: {} // Not currently supported + }; + const clientDataJSON = JSON.stringify(collectedClientData); + const clientDataJSONBytes = Utils.fromByteStringToArray(clientDataJSON); + const clientDataHash = await crypto.subtle.digest({ name: "SHA-256" }, clientDataJSONBytes); + const makeCredentialParams = mapToMakeCredentialParams({ + params, + credTypesAndPubKeyAlgs, + clientDataHash, + }); + + // Set timeout before invoking authenticator + if (abortController.signal.aborted) { + this.logService?.info(`[Fido2Client] Aborted with AbortController`); + throw new DOMException("The operation either timed out or was not allowed.", "AbortError"); + } + const timeout = setAbortTimeout( + abortController, + params.authenticatorSelection?.userVerification, + params.timeout + ); + + let makeCredentialResult; + try { + makeCredentialResult = await this.authenticator.makeCredential( + makeCredentialParams, + tab, + abortController + ); + } catch (error) { + if ( + abortController.signal.aborted && + abortController.signal.reason === UserRequestedFallbackAbortReason + ) { + this.logService?.info(`[Fido2Client] Aborting because user requested fallback`); + throw new FallbackRequestedError(); + } + + if ( + error instanceof Fido2AutenticatorError && + error.errorCode === Fido2AutenticatorErrorCode.InvalidState + ) { + this.logService?.warning(`[Fido2Client] Unknown error: ${error}`); + throw new DOMException("Unknown error occured.", "InvalidStateError"); + } + + this.logService?.info(`[Fido2Client] Aborted by user: ${error}`); + throw new DOMException( + "The operation either timed out or was not allowed.", + "NotAllowedError" + ); + } + + if (abortController.signal.aborted) { + this.logService?.info(`[Fido2Client] Aborted with AbortController`); + throw new DOMException("The operation either timed out or was not allowed.", "AbortError"); + } + + clearTimeout(timeout); + return { + credentialId: Fido2Utils.bufferToString(makeCredentialResult.credentialId), + attestationObject: Fido2Utils.bufferToString(makeCredentialResult.attestationObject), + authData: Fido2Utils.bufferToString(makeCredentialResult.authData), + clientDataJSON: Fido2Utils.bufferToString(clientDataJSONBytes), + publicKeyAlgorithm: makeCredentialResult.publicKeyAlgorithm, + transports: ["internal"], + }; + } + + async assertCredential( + params: AssertCredentialParams, + tab: chrome.tabs.Tab, + abortController = new AbortController() + ): Promise { + const enableFido2VaultCredentials = await this.isFido2FeatureEnabled(); + + if (!enableFido2VaultCredentials) { + this.logService?.warning(`[Fido2Client] Fido2VaultCredential is not enabled`); + throw new FallbackRequestedError(); + } + + const authStatus = await this.authService.getAuthStatus(); + + if (authStatus === AuthenticationStatus.LoggedOut) { + this.logService?.warning(`[Fido2Client] Fido2VaultCredential is not enabled`); + throw new FallbackRequestedError(); + } + + const { domain: effectiveDomain } = parse(params.origin, { allowPrivateDomains: true }); + if (effectiveDomain == undefined) { + this.logService?.warning(`[Fido2Client] Invalid origin: ${params.origin}`); + throw new DOMException("'origin' is not a valid domain", "SecurityError"); + } + + const parsedOrigin = parse(params.origin, { allowPrivateDomains: true }); + params.rpId = params.rpId ?? parsedOrigin.hostname; + + if (parsedOrigin.hostname == undefined || !params.origin.startsWith("https://")) { + this.logService?.warning(`[Fido2Client] Invalid https origin: ${params.origin}`); + throw new DOMException("'origin' is not a valid https origin", "SecurityError"); + } + + if (!isValidRpId(params.rpId, params.origin)) { + this.logService?.warning( + `[Fido2Client] 'rp.id' cannot be used with the current origin: rp.id = ${params.rpId}; origin = ${params.origin}` + ); + throw new DOMException("'rp.id' cannot be used with the current origin", "SecurityError"); + } + + const collectedClientData = { + type: "webauthn.get", + challenge: params.challenge, + origin: params.origin, + crossOrigin: !params.sameOriginWithAncestors, + // tokenBinding: {} // Not currently supported + }; + const clientDataJSON = JSON.stringify(collectedClientData); + const clientDataJSONBytes = Utils.fromByteStringToArray(clientDataJSON); + const clientDataHash = await crypto.subtle.digest({ name: "SHA-256" }, clientDataJSONBytes); + const getAssertionParams = mapToGetAssertionParams({ params, clientDataHash }); + + if (abortController.signal.aborted) { + this.logService?.info(`[Fido2Client] Aborted with AbortController`); + throw new DOMException("The operation either timed out or was not allowed.", "AbortError"); + } + + const timeout = setAbortTimeout(abortController, params.userVerification, params.timeout); + + let getAssertionResult; + try { + getAssertionResult = await this.authenticator.getAssertion( + getAssertionParams, + tab, + abortController + ); + } catch (error) { + if ( + abortController.signal.aborted && + abortController.signal.reason === UserRequestedFallbackAbortReason + ) { + this.logService?.info(`[Fido2Client] Aborting because user requested fallback`); + throw new FallbackRequestedError(); + } + + if ( + error instanceof Fido2AutenticatorError && + error.errorCode === Fido2AutenticatorErrorCode.InvalidState + ) { + this.logService?.warning(`[Fido2Client] Unknown error: ${error}`); + throw new DOMException("Unknown error occured.", "InvalidStateError"); + } + + this.logService?.info(`[Fido2Client] Aborted by user: ${error}`); + throw new DOMException( + "The operation either timed out or was not allowed.", + "NotAllowedError" + ); + } + + if (abortController.signal.aborted) { + this.logService?.info(`[Fido2Client] Aborted with AbortController`); + throw new DOMException("The operation either timed out or was not allowed.", "AbortError"); + } + clearTimeout(timeout); + + return { + authenticatorData: Fido2Utils.bufferToString(getAssertionResult.authenticatorData), + clientDataJSON: Fido2Utils.bufferToString(clientDataJSONBytes), + credentialId: Fido2Utils.bufferToString(getAssertionResult.selectedCredential.id), + userHandle: + getAssertionResult.selectedCredential.userHandle !== undefined + ? Fido2Utils.bufferToString(getAssertionResult.selectedCredential.userHandle) + : undefined, + signature: Fido2Utils.bufferToString(getAssertionResult.signature), + }; + } +} + +const TIMEOUTS = { + NO_VERIFICATION: { + DEFAULT: 120000, + MIN: 30000, + MAX: 180000, + }, + WITH_VERIFICATION: { + DEFAULT: 300000, + MIN: 30000, + MAX: 600000, + }, +}; + +function setAbortTimeout( + abortController: AbortController, + userVerification?: UserVerification, + timeout?: number +): number { + let clampedTimeout: number; + + if (userVerification === "required") { + timeout = timeout ?? TIMEOUTS.WITH_VERIFICATION.DEFAULT; + clampedTimeout = Math.max( + TIMEOUTS.WITH_VERIFICATION.MIN, + Math.min(timeout, TIMEOUTS.WITH_VERIFICATION.MAX) + ); + } else { + timeout = timeout ?? TIMEOUTS.NO_VERIFICATION.DEFAULT; + clampedTimeout = Math.max( + TIMEOUTS.NO_VERIFICATION.MIN, + Math.min(timeout, TIMEOUTS.NO_VERIFICATION.MAX) + ); + } + + return window.setTimeout(() => abortController.abort(), clampedTimeout); +} + +/** + * Convert data gathered by the WebAuthn Client to a format that can be used by the authenticator. + */ +function mapToMakeCredentialParams({ + params, + credTypesAndPubKeyAlgs, + clientDataHash, +}: { + params: CreateCredentialParams; + credTypesAndPubKeyAlgs: PublicKeyCredentialParam[]; + clientDataHash: ArrayBuffer; +}): Fido2AuthenticatorMakeCredentialsParams { + const excludeCredentialDescriptorList: PublicKeyCredentialDescriptor[] = + params.excludeCredentials?.map((credential) => ({ + id: Fido2Utils.stringToBuffer(credential.id), + transports: credential.transports, + type: credential.type, + })) ?? []; + + const requireResidentKey = + params.authenticatorSelection?.residentKey === "required" || + params.authenticatorSelection?.residentKey === "preferred" || + (params.authenticatorSelection?.residentKey === undefined && + params.authenticatorSelection?.requireResidentKey === true); + + return { + requireResidentKey, + requireUserVerification: params.authenticatorSelection?.userVerification === "required", + enterpriseAttestationPossible: params.attestation === "enterprise", + excludeCredentialDescriptorList, + credTypesAndPubKeyAlgs, + hash: clientDataHash, + rpEntity: { + id: params.rp.id, + name: params.rp.name, + }, + userEntity: { + id: Fido2Utils.stringToBuffer(params.user.id), + displayName: params.user.displayName, + }, + fallbackSupported: params.fallbackSupported, + }; +} + +/** + * Convert data gathered by the WebAuthn Client to a format that can be used by the authenticator. + */ +function mapToGetAssertionParams({ + params, + clientDataHash, +}: { + params: AssertCredentialParams; + clientDataHash: ArrayBuffer; +}): Fido2AuthenticatorGetAssertionParams { + const allowCredentialDescriptorList: PublicKeyCredentialDescriptor[] = + params.allowedCredentialIds.map((id) => ({ + id: Fido2Utils.stringToBuffer(id), + type: "public-key", + })); + + return { + rpId: params.rpId, + requireUserVerification: params.userVerification === "required", + hash: clientDataHash, + allowCredentialDescriptorList, + extensions: {}, + fallbackSupported: params.fallbackSupported, + }; +} diff --git a/libs/common/src/vault/services/fido2/fido2-utils.ts b/libs/common/src/vault/services/fido2/fido2-utils.ts new file mode 100644 index 00000000000..a2de1375507 --- /dev/null +++ b/libs/common/src/vault/services/fido2/fido2-utils.ts @@ -0,0 +1,26 @@ +import { Utils } from "../../../platform/misc/utils"; + +export class Fido2Utils { + static bufferToString(bufferSource: BufferSource): string { + const buffer = Fido2Utils.bufferSourceToUint8Array(bufferSource); + + return Utils.fromBufferToUrlB64(buffer); + } + + static stringToBuffer(str: string): Uint8Array { + return Utils.fromUrlB64ToArray(str); + } + + static bufferSourceToUint8Array(bufferSource: BufferSource) { + if (Fido2Utils.isArrayBuffer(bufferSource)) { + return new Uint8Array(bufferSource); + } else { + return new Uint8Array(bufferSource.buffer); + } + } + + /** Utility function to identify type of bufferSource. Necessary because of differences between runtimes */ + private static isArrayBuffer(bufferSource: BufferSource): bufferSource is ArrayBuffer { + return bufferSource instanceof ArrayBuffer || bufferSource.buffer === undefined; + } +} diff --git a/libs/common/src/vault/services/fido2/guid-utils.ts b/libs/common/src/vault/services/fido2/guid-utils.ts new file mode 100644 index 00000000000..66e6cbb1d7c --- /dev/null +++ b/libs/common/src/vault/services/fido2/guid-utils.ts @@ -0,0 +1,95 @@ +/* + License for: guidToRawFormat, guidToStandardFormat + Source: https://github.com/uuidjs/uuid/ + The MIT License (MIT) + Copyright (c) 2010-2020 Robert Kieffer and other contributors + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.*/ + +import { Utils } from "../../../platform/misc/utils"; + +/** Private array used for optimization */ +const byteToHex = Array.from({ length: 256 }, (_, i) => (i + 0x100).toString(16).substring(1)); + +/** Convert standard format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX) UUID to raw 16 byte array. */ +export function guidToRawFormat(guid: string) { + if (!Utils.isGuid(guid)) { + throw TypeError("GUID parameter is invalid"); + } + + let v; + const arr = new Uint8Array(16); + + // Parse ########-....-....-....-............ + arr[0] = (v = parseInt(guid.slice(0, 8), 16)) >>> 24; + arr[1] = (v >>> 16) & 0xff; + arr[2] = (v >>> 8) & 0xff; + arr[3] = v & 0xff; + + // Parse ........-####-....-....-............ + arr[4] = (v = parseInt(guid.slice(9, 13), 16)) >>> 8; + arr[5] = v & 0xff; + + // Parse ........-....-####-....-............ + arr[6] = (v = parseInt(guid.slice(14, 18), 16)) >>> 8; + arr[7] = v & 0xff; + + // Parse ........-....-....-####-............ + arr[8] = (v = parseInt(guid.slice(19, 23), 16)) >>> 8; + arr[9] = v & 0xff; + + // Parse ........-....-....-....-############ + // (Use "/" to avoid 32-bit truncation when bit-shifting high-order bytes) + arr[10] = ((v = parseInt(guid.slice(24, 36), 16)) / 0x10000000000) & 0xff; + arr[11] = (v / 0x100000000) & 0xff; + arr[12] = (v >>> 24) & 0xff; + arr[13] = (v >>> 16) & 0xff; + arr[14] = (v >>> 8) & 0xff; + arr[15] = v & 0xff; + + return arr; +} + +/** Convert raw 16 byte array to standard format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX) UUID. */ +export function guidToStandardFormat(bufferSource: BufferSource) { + const arr = + bufferSource instanceof ArrayBuffer + ? new Uint8Array(bufferSource) + : new Uint8Array(bufferSource.buffer); + // Note: Be careful editing this code! It's been tuned for performance + // and works in ways you may not expect. See https://github.com/uuidjs/uuid/pull/434 + const guid = ( + byteToHex[arr[0]] + + byteToHex[arr[1]] + + byteToHex[arr[2]] + + byteToHex[arr[3]] + + "-" + + byteToHex[arr[4]] + + byteToHex[arr[5]] + + "-" + + byteToHex[arr[6]] + + byteToHex[arr[7]] + + "-" + + byteToHex[arr[8]] + + byteToHex[arr[9]] + + "-" + + byteToHex[arr[10]] + + byteToHex[arr[11]] + + byteToHex[arr[12]] + + byteToHex[arr[13]] + + byteToHex[arr[14]] + + byteToHex[arr[15]] + ).toLowerCase(); + + // Consistency check for valid UUID. If this throws, it's likely due to one + // of the following: + // - One or more input array values don't map to a hex octet (leading to + // "undefined" in the uuid) + // - Invalid input values for the RFC `version` or `variant` fields + if (!Utils.isGuid(guid)) { + throw TypeError("Converted GUID is invalid"); + } + + return guid; +} diff --git a/libs/common/src/vault/services/fido2/noop-fido2-user-interface.service.ts b/libs/common/src/vault/services/fido2/noop-fido2-user-interface.service.ts new file mode 100644 index 00000000000..440bd519002 --- /dev/null +++ b/libs/common/src/vault/services/fido2/noop-fido2-user-interface.service.ts @@ -0,0 +1,14 @@ +import { + Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction, + Fido2UserInterfaceSession, +} from "../../abstractions/fido2/fido2-user-interface.service.abstraction"; + +/** + * Noop implementation of the {@link Fido2UserInterfaceService}. + * This implementation does not provide any user interface. + */ +export class Fido2UserInterfaceService implements Fido2UserInterfaceServiceAbstraction { + newSession(): Promise { + throw new Error("Not implemented exception"); + } +} diff --git a/package-lock.json b/package-lock.json index 70cf905b80f..cdff4a8c214 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "bootstrap": "4.6.0", "braintree-web-drop-in": "1.40.0", "bufferutil": "4.0.7", + "cbor-redux": "^0.4.0", "chalk": "4.1.2", "commander": "7.2.0", "core-js": "3.32.0", @@ -17669,6 +17670,11 @@ "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", "dev": true }, + "node_modules/cbor-redux": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cbor-redux/-/cbor-redux-0.4.0.tgz", + "integrity": "sha512-jP8BB9zF2uVTwbNXe7kRNIQRmKFMNKZcx0A+TCc6v3kJHoIKzKexQ+DjvXP/G5HuPF88myDdVE2grBOizsbMxg==" + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",