diff --git a/apps/browser/src/auth/popup/login.component.ts b/apps/browser/src/auth/popup/login.component.ts index d9b789e4d8..b930f2747d 100644 --- a/apps/browser/src/auth/popup/login.component.ts +++ b/apps/browser/src/auth/popup/login.component.ts @@ -7,6 +7,7 @@ import { FormValidationErrorsService } from "@bitwarden/angular/platform/abstrac import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; +import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; @@ -43,7 +44,8 @@ export class LoginComponent extends BaseLoginComponent { formBuilder: FormBuilder, formValidationErrorService: FormValidationErrorsService, route: ActivatedRoute, - loginService: LoginService + loginService: LoginService, + webAuthnLoginService: WebAuthnLoginServiceAbstraction ) { super( devicesApiService, @@ -61,7 +63,8 @@ export class LoginComponent extends BaseLoginComponent { formBuilder, formValidationErrorService, route, - loginService + loginService, + webAuthnLoginService ); super.onSuccessfulLogin = async () => { await syncService.fullSync(true); diff --git a/apps/desktop/src/auth/login/login.component.ts b/apps/desktop/src/auth/login/login.component.ts index d1c6c88d14..8c182cdb21 100644 --- a/apps/desktop/src/auth/login/login.component.ts +++ b/apps/desktop/src/auth/login/login.component.ts @@ -10,6 +10,7 @@ import { ModalService } from "@bitwarden/angular/services/modal.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; +import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service"; @@ -71,7 +72,8 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy { formBuilder: FormBuilder, formValidationErrorService: FormValidationErrorsService, route: ActivatedRoute, - loginService: LoginService + loginService: LoginService, + webAuthnLoginService: WebAuthnLoginServiceAbstraction ) { super( devicesApiService, @@ -89,7 +91,8 @@ export class LoginComponent extends BaseLoginComponent implements OnDestroy { formBuilder, formValidationErrorService, route, - loginService + loginService, + webAuthnLoginService ); super.onSuccessfulLogin = () => { return syncService.fullSync(true); diff --git a/apps/web/src/app/auth/core/services/webauthn-login/utils.ts b/apps/web/src/app/auth/core/services/webauthn-login/utils.ts deleted file mode 100644 index 46995eedfb..0000000000 --- a/apps/web/src/app/auth/core/services/webauthn-login/utils.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { - PrfKey, - SymmetricCryptoKey, -} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; - -const LoginWithPrfSalt = "passwordless-login"; - -export async function getLoginWithPrfSalt(): Promise { - return await crypto.subtle.digest("sha-256", Utils.fromUtf8ToArray(LoginWithPrfSalt)); -} - -export function createSymmetricKeyFromPrf(prf: ArrayBuffer) { - return new SymmetricCryptoKey(new Uint8Array(prf)) as PrfKey; -} diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-api.service.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts similarity index 97% rename from apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-api.service.ts rename to apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts index 6dc6156349..54789d5674 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-api.service.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin-api.service.ts @@ -9,7 +9,7 @@ import { WebauthnLoginCredentialCreateOptionsResponse } from "./response/webauth import { WebauthnLoginCredentialResponse } from "./response/webauthn-login-credential.response"; @Injectable({ providedIn: "root" }) -export class WebauthnLoginApiService { +export class WebAuthnLoginAdminApiService { constructor(private apiService: ApiService) {} async getCredentialCreateOptions( diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts index 3094c4fccb..92a560201d 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts @@ -1,18 +1,20 @@ import { mock, MockProxy } from "jest-mock-extended"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { WebAuthnLoginPrfCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction"; import { CredentialCreateOptionsView } from "../../views/credential-create-options.view"; import { PendingWebauthnLoginCredentialView } from "../../views/pending-webauthn-login-credential.view"; import { RotateableKeySetService } from "../rotateable-key-set.service"; +import { WebAuthnLoginAdminApiService } from "./webauthn-login-admin-api.service"; import { WebauthnLoginAdminService } from "./webauthn-login-admin.service"; -import { WebauthnLoginApiService } from "./webauthn-login-api.service"; describe("WebauthnAdminService", () => { - let apiService!: MockProxy; + let apiService!: MockProxy; let userVerificationService!: MockProxy; let rotateableKeySetService!: MockProxy; + let webAuthnLoginPrfCryptoService!: MockProxy; let credentials: MockProxy; let service!: WebauthnLoginAdminService; @@ -20,14 +22,16 @@ describe("WebauthnAdminService", () => { // Polyfill missing class window.PublicKeyCredential = class {} as any; window.AuthenticatorAttestationResponse = class {} as any; - apiService = mock(); + apiService = mock(); userVerificationService = mock(); rotateableKeySetService = mock(); + webAuthnLoginPrfCryptoService = mock(); credentials = mock(); service = new WebauthnLoginAdminService( apiService, userVerificationService, rotateableKeySetService, + webAuthnLoginPrfCryptoService, credentials ); }); diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts index 74b925da2c..891d403b43 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts @@ -3,6 +3,7 @@ import { BehaviorSubject, filter, from, map, Observable, shareReplay, switchMap, import { PrfKeySet } from "@bitwarden/auth"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { WebAuthnLoginPrfCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Verification } from "@bitwarden/common/types/verification"; @@ -13,10 +14,12 @@ import { RotateableKeySetService } from "../rotateable-key-set.service"; import { SaveCredentialRequest } from "./request/save-credential.request"; import { WebauthnLoginAttestationResponseRequest } from "./request/webauthn-login-attestation-response.request"; -import { createSymmetricKeyFromPrf, getLoginWithPrfSalt } from "./utils"; -import { WebauthnLoginApiService } from "./webauthn-login-api.service"; +import { WebAuthnLoginAdminApiService } from "./webauthn-login-admin-api.service"; @Injectable({ providedIn: "root" }) +/** + * Service for managing WebAuthnLogin credentials. + */ export class WebauthnLoginAdminService { static readonly MaxCredentialCount = 5; @@ -30,12 +33,17 @@ export class WebauthnLoginAdminService { shareReplay({ bufferSize: 1, refCount: true }) ); + /** + * An Observable that emits a boolean indicating whether the service is currently fetching + * WebAuthnLogin credentials from the server. + */ readonly loading$ = this._loading$.asObservable(); constructor( - private apiService: WebauthnLoginApiService, + private apiService: WebAuthnLoginAdminApiService, private userVerificationService: UserVerificationService, private rotateableKeySetService: RotateableKeySetService, + private webAuthnLoginPrfCryptoService: WebAuthnLoginPrfCryptoServiceAbstraction, @Optional() navigatorCredentials?: CredentialsContainer, @Optional() private logService?: LogService ) { @@ -43,6 +51,14 @@ export class WebauthnLoginAdminService { this.navigatorCredentials = navigatorCredentials ?? navigator.credentials; } + /** + * Get the credential attestation options needed for initiating the WebAuthnLogin credentail creation process. + * The options contains a challenge and other data for the authenticator. + * This method requires user verification. + * + * @param verification User verification data to be used for the request. + * @returns The credential attestation options and a token to be used for the credential creation request. + */ async getCredentialCreateOptions( verification: Verification ): Promise { @@ -51,6 +67,12 @@ export class WebauthnLoginAdminService { return new CredentialCreateOptionsView(response.options, response.token); } + /** + * Create a credential using the given options. This triggers the browsers WebAuthn API to create a credential. + * + * @param credentialOptions Options received from the server using `getCredentialCreateOptions`. + * @returns A pending credential that can be saved to server directly or be used to create a key set. + */ async createCredential( credentialOptions: CredentialCreateOptionsView ): Promise { @@ -76,6 +98,13 @@ export class WebauthnLoginAdminService { } } + /** + * Create a key set from the given pending credential. The credential must support PRF. + * This will trigger the browsers WebAuthn API to generate a PRF-output. + * + * @param pendingCredential A credential created using `createCredential`. + * @returns A key set that can be saved to the server. Undefined is returned if the credential doesn't support PRF. + */ async createKeySet( pendingCredential: PendingWebauthnLoginCredentialView ): Promise { @@ -88,7 +117,9 @@ export class WebauthnLoginAdminService { userVerification: pendingCredential.createOptions.options.authenticatorSelection.userVerification, // TODO: Remove `any` when typescript typings add support for PRF - extensions: { prf: { eval: { first: await getLoginWithPrfSalt() } } } as any, + extensions: { + prf: { eval: { first: await this.webAuthnLoginPrfCryptoService.getLoginWithPrfSalt() } }, + } as any, }, }; @@ -105,7 +136,9 @@ export class WebauthnLoginAdminService { return undefined; } - const symmetricPrfKey = createSymmetricKeyFromPrf(prfResult); + const symmetricPrfKey = await this.webAuthnLoginPrfCryptoService.createSymmetricKeyFromPrf( + prfResult + ); return await this.rotateableKeySetService.createKeySet(symmetricPrfKey); } catch (error) { this.logService?.error(error); @@ -113,6 +146,13 @@ export class WebauthnLoginAdminService { } } + /** + * Save a pending credential to the server. This will also save the key set if it is provided. + * + * @param name User provided name for the credential. + * @param credential A pending credential created using `createCredential`. + * @param prfKeySet A key set created using `createKeySet`. + */ async saveCredential( name: string, credential: PendingWebauthnLoginCredentialView, @@ -144,6 +184,12 @@ export class WebauthnLoginAdminService { return this.credentials$; } + /** + * Subscribe to a single credential by id. + * + * @param credentialId The id of the credential to subscribe to. + * @returns An observable that emits the credential with the given id. + */ getCredential$(credentialId: string): Observable { return this.credentials$.pipe( map((credentials) => credentials.find((c) => c.id === credentialId)), @@ -151,6 +197,13 @@ export class WebauthnLoginAdminService { ); } + /** + * Delete a credential from the server. This method requires user verification. + * + * @param credentialId The id of the credential to delete. + * @param verification User verification data to be used for the request. + * @returns A promise that resolves when the credential has been deleted. + */ async deleteCredential(credentialId: string, verification: Verification): Promise { const request = await this.userVerificationService.buildRequest(verification); await this.apiService.deleteCredential(credentialId, request); diff --git a/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.html b/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.html new file mode 100644 index 0000000000..cb6ec90274 --- /dev/null +++ b/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.html @@ -0,0 +1,50 @@ +
+
+ +

+ {{ "readingPasskeyLoading" | i18n }} +

+ +
+
+ + +

{{ "readingPasskeyLoadingInfo" | i18n }}

+ +
+ + + +

{{ "readingPasskeyLoadingInfo" | i18n }}

+ +
+
+

+ {{ "troubleLoggingIn" | i18n }}
+ {{ "useADifferentLogInMethod" | i18n }} +

+
+
+
diff --git a/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.ts b/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.ts new file mode 100644 index 0000000000..8f3a5ca3c3 --- /dev/null +++ b/apps/web/src/app/auth/login/login-via-webauthn/login-via-webauthn.component.ts @@ -0,0 +1,13 @@ +import { Component } from "@angular/core"; + +import { BaseLoginViaWebAuthnComponent } from "@bitwarden/angular/auth/components/base-login-via-webauthn.component"; +import { CreatePasskeyFailedIcon } from "@bitwarden/angular/auth/icons/create-passkey-failed.icon"; +import { CreatePasskeyIcon } from "@bitwarden/angular/auth/icons/create-passkey.icon"; + +@Component({ + selector: "app-login-via-webauthn", + templateUrl: "login-via-webauthn.component.html", +}) +export class LoginViaWebAuthnComponent extends BaseLoginViaWebAuthnComponent { + protected readonly Icons = { CreatePasskeyIcon, CreatePasskeyFailedIcon }; +} diff --git a/apps/web/src/app/auth/login/login.component.html b/apps/web/src/app/auth/login/login.component.html index f9ff828e94..72315b1a4d 100644 --- a/apps/web/src/app/auth/login/login.component.html +++ b/apps/web/src/app/auth/login/login.component.html @@ -51,6 +51,23 @@ +
+

{{ "or" | i18n }}

+ + + {{ "loginWithPasskey" | i18n }} + +
+

diff --git a/apps/web/src/app/auth/login/login.component.ts b/apps/web/src/app/auth/login/login.component.ts index c67c84286a..3a5c6d0eb2 100644 --- a/apps/web/src/app/auth/login/login.component.ts +++ b/apps/web/src/app/auth/login/login.component.ts @@ -15,6 +15,7 @@ import { PolicyResponse } from "@bitwarden/common/admin-console/models/response/ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; +import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; @@ -62,7 +63,8 @@ export class LoginComponent extends BaseLoginComponent implements OnInit { private routerService: RouterService, formBuilder: FormBuilder, formValidationErrorService: FormValidationErrorsService, - loginService: LoginService + loginService: LoginService, + webAuthnLoginService: WebAuthnLoginServiceAbstraction ) { super( devicesApiService, @@ -80,7 +82,8 @@ export class LoginComponent extends BaseLoginComponent implements OnInit { formBuilder, formValidationErrorService, route, - loginService + loginService, + webAuthnLoginService ); this.onSuccessfulLogin = async () => { this.messagingService.send("setFullWidth"); diff --git a/apps/web/src/app/auth/login/login.module.ts b/apps/web/src/app/auth/login/login.module.ts index eb3d58cddf..ee3af10109 100644 --- a/apps/web/src/app/auth/login/login.module.ts +++ b/apps/web/src/app/auth/login/login.module.ts @@ -6,11 +6,22 @@ import { SharedModule } from "../../../app/shared"; import { LoginDecryptionOptionsComponent } from "./login-decryption-options/login-decryption-options.component"; import { LoginViaAuthRequestComponent } from "./login-via-auth-request.component"; +import { LoginViaWebAuthnComponent } from "./login-via-webauthn/login-via-webauthn.component"; import { LoginComponent } from "./login.component"; @NgModule({ imports: [SharedModule, CheckboxModule], - declarations: [LoginComponent, LoginViaAuthRequestComponent, LoginDecryptionOptionsComponent], - exports: [LoginComponent, LoginViaAuthRequestComponent, LoginDecryptionOptionsComponent], + declarations: [ + LoginComponent, + LoginViaAuthRequestComponent, + LoginDecryptionOptionsComponent, + LoginViaWebAuthnComponent, + ], + exports: [ + LoginComponent, + LoginViaAuthRequestComponent, + LoginDecryptionOptionsComponent, + LoginViaWebAuthnComponent, + ], }) export class LoginModule {} diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 5f5baab2e3..a59576a77b 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -24,6 +24,7 @@ import { HintComponent } from "./auth/hint.component"; import { LockComponent } from "./auth/lock.component"; import { LoginDecryptionOptionsComponent } from "./auth/login/login-decryption-options/login-decryption-options.component"; import { LoginViaAuthRequestComponent } from "./auth/login/login-via-auth-request.component"; +import { LoginViaWebAuthnComponent } from "./auth/login/login-via-webauthn/login-via-webauthn.component"; import { LoginComponent } from "./auth/login/login.component"; import { RecoverDeleteComponent } from "./auth/recover-delete.component"; import { RecoverTwoFactorComponent } from "./auth/recover-two-factor.component"; @@ -70,6 +71,11 @@ const routes: Routes = [ component: LoginViaAuthRequestComponent, data: { titleId: "loginWithDevice" }, }, + { + path: "login-with-passkey", + component: LoginViaWebAuthnComponent, + data: { titleId: "loginWithPasskey" }, + }, { path: "admin-approval-requested", component: LoginViaAuthRequestComponent, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 702f94e7e4..6d0a3a8e3e 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -614,6 +614,12 @@ "loginWithPasskey": { "message": "Log in with passkey" }, + "invalidPasskeyPleaseTryAgain": { + "message": "Invalid Passkey. Please try again." + }, + "twoFactorForPasskeysNotSupportedOnClientUpdateToLogIn": { + "message": "2FA for passkeys is not supported. Update the app to log in." + }, "loginWithPasskeyInfo": { "message": "Use a generated passkey that will automatically log you in without a password. Biometrics, like facial recognition or fingerprint, or another FIDO2 security method will verify your identity." }, diff --git a/libs/angular/src/auth/components/base-login-via-webauthn.component.ts b/libs/angular/src/auth/components/base-login-via-webauthn.component.ts new file mode 100644 index 0000000000..bcc3747667 --- /dev/null +++ b/libs/angular/src/auth/components/base-login-via-webauthn.component.ts @@ -0,0 +1,69 @@ +import { Directive, OnInit } from "@angular/core"; +import { Router } from "@angular/router"; + +import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction"; +import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view"; +import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; + +export type State = "assert" | "assertFailed"; + +@Directive() +export class BaseLoginViaWebAuthnComponent implements OnInit { + protected currentState: State = "assert"; + + protected successRoute = "/vault"; + protected forcePasswordResetRoute = "/update-temp-password"; + + constructor( + private webAuthnLoginService: WebAuthnLoginServiceAbstraction, + private router: Router, + private logService: LogService, + private validationService: ValidationService, + private i18nService: I18nService + ) {} + + ngOnInit(): void { + this.authenticate(); + } + + protected retry() { + this.currentState = "assert"; + this.authenticate(); + } + + private async authenticate() { + let assertion: WebAuthnLoginCredentialAssertionView; + try { + const options = await this.webAuthnLoginService.getCredentialAssertionOptions(); + assertion = await this.webAuthnLoginService.assertCredential(options); + } catch (error) { + this.validationService.showError(error); + this.currentState = "assertFailed"; + return; + } + try { + const authResult = await this.webAuthnLoginService.logIn(assertion); + + if (authResult.requiresTwoFactor) { + this.validationService.showError( + this.i18nService.t("twoFactorForPasskeysNotSupportedOnClientUpdateToLogIn") + ); + this.currentState = "assertFailed"; + } else if (authResult.forcePasswordReset == ForceSetPasswordReason.AdminForcePasswordReset) { + await this.router.navigate([this.forcePasswordResetRoute]); + } else { + await this.router.navigate([this.successRoute]); + } + } catch (error) { + if (error instanceof ErrorResponse) { + this.validationService.showError(this.i18nService.t("invalidPasskeyPleaseTryAgain")); + } + this.logService.error(error); + this.currentState = "assertFailed"; + } + } +} diff --git a/libs/angular/src/auth/components/login.component.ts b/libs/angular/src/auth/components/login.component.ts index cc81175b19..d344688cc5 100644 --- a/libs/angular/src/auth/components/login.component.ts +++ b/libs/angular/src/auth/components/login.component.ts @@ -1,12 +1,13 @@ import { Directive, ElementRef, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { Subject } from "rxjs"; +import { Observable, Subject } from "rxjs"; import { take, takeUntil } from "rxjs/operators"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction"; import { LoginService } from "@bitwarden/common/auth/abstractions/login.service"; +import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction"; import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { PasswordLoginCredentials } from "@bitwarden/common/auth/models/domain/login-credentials"; @@ -53,6 +54,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, protected twoFactorRoute = "2fa"; protected successRoute = "vault"; protected forcePasswordResetRoute = "update-temp-password"; + protected showWebauthnLogin$: Observable; protected destroy$ = new Subject(); @@ -76,7 +78,8 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, protected formBuilder: FormBuilder, protected formValidationErrorService: FormValidationErrorsService, protected route: ActivatedRoute, - protected loginService: LoginService + protected loginService: LoginService, + protected webAuthnLoginService: WebAuthnLoginServiceAbstraction ) { super(environmentService, i18nService, platformUtilsService); } @@ -86,6 +89,8 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit, } async ngOnInit() { + this.showWebauthnLogin$ = this.webAuthnLoginService.enabled$; + this.route?.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => { if (!params) { return; diff --git a/libs/angular/src/auth/icons/create-passkey-failed.icon.ts b/libs/angular/src/auth/icons/create-passkey-failed.icon.ts new file mode 100644 index 0000000000..39a2389c5a --- /dev/null +++ b/libs/angular/src/auth/icons/create-passkey-failed.icon.ts @@ -0,0 +1,28 @@ +import { svgIcon } from "@bitwarden/components"; + +export const CreatePasskeyFailedIcon = svgIcon` + + + + + + + + + + + +`; diff --git a/libs/angular/src/auth/icons/create-passkey.icon.ts b/libs/angular/src/auth/icons/create-passkey.icon.ts new file mode 100644 index 0000000000..c0e984bbee --- /dev/null +++ b/libs/angular/src/auth/icons/create-passkey.icon.ts @@ -0,0 +1,26 @@ +import { svgIcon } from "@bitwarden/components"; + +export const CreatePasskeyIcon = svgIcon` + + + + + + + + + + +`; diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 1cbb152618..fedbf43391 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -54,6 +54,9 @@ import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/ import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service"; import { UserVerificationApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification-api.service.abstraction"; import { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { WebAuthnLoginApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-api.service.abstraction"; +import { WebAuthnLoginPrfCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction"; +import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction"; import { AccountApiServiceImplementation } from "@bitwarden/common/auth/services/account-api.service"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { AuthRequestCryptoServiceImplementation } from "@bitwarden/common/auth/services/auth-request-crypto.service.implementation"; @@ -68,6 +71,9 @@ import { TokenService } from "@bitwarden/common/auth/services/token.service"; import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service"; import { UserVerificationApiService } from "@bitwarden/common/auth/services/user-verification/user-verification-api.service"; import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service"; +import { WebAuthnLoginApiService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-api.service"; +import { WebAuthnLoginPrfCryptoService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-prf-crypto.service"; +import { WebAuthnLoginService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login.service"; import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service"; import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; @@ -752,6 +758,28 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; useClass: AuthRequestCryptoServiceImplementation, deps: [CryptoServiceAbstraction], }, + { + provide: WebAuthnLoginPrfCryptoServiceAbstraction, + useClass: WebAuthnLoginPrfCryptoService, + deps: [CryptoFunctionServiceAbstraction], + }, + { + provide: WebAuthnLoginApiServiceAbstraction, + useClass: WebAuthnLoginApiService, + deps: [ApiServiceAbstraction, EnvironmentServiceAbstraction], + }, + { + provide: WebAuthnLoginServiceAbstraction, + useClass: WebAuthnLoginService, + deps: [ + WebAuthnLoginApiServiceAbstraction, + AuthServiceAbstraction, + ConfigServiceAbstraction, + WebAuthnLoginPrfCryptoServiceAbstraction, + WINDOW, + LogService, + ], + }, { provide: GlobalStateProvider, useClass: DefaultGlobalStateProvider, diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 1a8ca440d2..d8ce416fda 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -38,6 +38,7 @@ import { EmailRequest } from "../auth/models/request/email.request"; import { PasswordTokenRequest } from "../auth/models/request/identity-token/password-token.request"; import { SsoTokenRequest } from "../auth/models/request/identity-token/sso-token.request"; import { UserApiTokenRequest } from "../auth/models/request/identity-token/user-api-token.request"; +import { WebAuthnLoginTokenRequest } from "../auth/models/request/identity-token/webauthn-login-token.request"; import { KeyConnectorUserKeyRequest } from "../auth/models/request/key-connector-user-key.request"; import { PasswordHintRequest } from "../auth/models/request/password-hint.request"; import { PasswordRequest } from "../auth/models/request/password.request"; @@ -144,7 +145,11 @@ export abstract class ApiService { ) => Promise; postIdentityToken: ( - request: PasswordTokenRequest | SsoTokenRequest | UserApiTokenRequest + request: + | PasswordTokenRequest + | SsoTokenRequest + | UserApiTokenRequest + | WebAuthnLoginTokenRequest ) => Promise; refreshIdentityToken: () => Promise; diff --git a/libs/common/src/auth/abstractions/auth.service.ts b/libs/common/src/auth/abstractions/auth.service.ts index 27107b708b..58931c7509 100644 --- a/libs/common/src/auth/abstractions/auth.service.ts +++ b/libs/common/src/auth/abstractions/auth.service.ts @@ -9,6 +9,7 @@ import { PasswordLoginCredentials, SsoLoginCredentials, AuthRequestLoginCredentials, + WebAuthnLoginCredentials, } from "../models/domain/login-credentials"; import { TokenTwoFactorRequest } from "../models/request/identity-token/token-two-factor.request"; import { AuthRequestResponse } from "../models/response/auth-request.response"; @@ -26,6 +27,7 @@ export abstract class AuthService { | PasswordLoginCredentials | SsoLoginCredentials | AuthRequestLoginCredentials + | WebAuthnLoginCredentials ) => Promise; logInTwoFactor: ( twoFactor: TokenTwoFactorRequest, diff --git a/libs/common/src/auth/abstractions/webauthn/webauthn-login-api.service.abstraction.ts b/libs/common/src/auth/abstractions/webauthn/webauthn-login-api.service.abstraction.ts new file mode 100644 index 0000000000..01845a2e3d --- /dev/null +++ b/libs/common/src/auth/abstractions/webauthn/webauthn-login-api.service.abstraction.ts @@ -0,0 +1,5 @@ +import { CredentialAssertionOptionsResponse } from "../../services/webauthn-login/response/credential-assertion-options.response"; + +export class WebAuthnLoginApiServiceAbstraction { + getCredentialAssertionOptions: () => Promise; +} diff --git a/libs/common/src/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction.ts b/libs/common/src/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction.ts new file mode 100644 index 0000000000..5ce58a06a9 --- /dev/null +++ b/libs/common/src/auth/abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction.ts @@ -0,0 +1,17 @@ +import { PrfKey } from "../../../platform/models/domain/symmetric-crypto-key"; + +/** + * Contains methods for all crypto operations specific to the WebAuthn login flow. + */ +export abstract class WebAuthnLoginPrfCryptoServiceAbstraction { + /** + * Get the salt used to generate the PRF-output used when logging in with WebAuthn. + */ + getLoginWithPrfSalt: () => Promise; + + /** + * Create a symmetric key from the PRF-output by stretching it. + * This should be used as `ExternalKey` with `RotateableKeySet`. + */ + createSymmetricKeyFromPrf: (prf: ArrayBuffer) => Promise; +} diff --git a/libs/common/src/auth/abstractions/webauthn/webauthn-login.service.abstraction.ts b/libs/common/src/auth/abstractions/webauthn/webauthn-login.service.abstraction.ts new file mode 100644 index 0000000000..529c2e2016 --- /dev/null +++ b/libs/common/src/auth/abstractions/webauthn/webauthn-login.service.abstraction.ts @@ -0,0 +1,48 @@ +import { Observable } from "rxjs"; + +import { AuthResult } from "../../models/domain/auth-result"; +import { WebAuthnLoginCredentialAssertionOptionsView } from "../../models/view/webauthn-login/webauthn-login-credential-assertion-options.view"; +import { WebAuthnLoginCredentialAssertionView } from "../../models/view/webauthn-login/webauthn-login-credential-assertion.view"; + +/** + * Service for logging in with WebAuthnLogin credentials. + */ +export abstract class WebAuthnLoginServiceAbstraction { + /** + * An Observable that emits a boolean indicating whether the WebAuthn login feature is enabled. + */ + readonly enabled$: Observable; + + /** + * Gets the credential assertion options needed for initiating the WebAuthn + * authentication process. It should provide the challenge and other data + * (whether FIDO2 user verification is required, the relying party id, timeout duration for the process to complete, etc.) + * for the authenticator. + */ + getCredentialAssertionOptions: () => Promise; + + /** + * Asserts the credential. This involves user interaction with the authenticator + * to sign a challenge with a private key (proving ownership of the private key). + * This will trigger the browsers WebAuthn API to assert a credential. A PRF-output might + * be included in the response if the authenticator supports it. + * + * @param {WebAuthnLoginCredentialAssertionOptionsView} credentialAssertionOptions - The options provided by the + * `getCredentialAssertionOptions` method, including the challenge and other data. + * @returns {WebAuthnLoginCredentialAssertionView} The assertion obtained from the authenticator. + * If the assertion is not successfully obtained, it returns undefined. + */ + assertCredential: ( + credentialAssertionOptions: WebAuthnLoginCredentialAssertionOptionsView + ) => Promise; + + /** + * Logs the user in using the assertion obtained from the authenticator. + * It completes the authentication process if the assertion is successfully validated server side: + * the server verifies the signed challenge with the corresponding public key. + * + * @param {WebAuthnLoginCredentialAssertionView} assertion - The assertion obtained from the authenticator + * that needs to be validated for login. + */ + logIn: (assertion: WebAuthnLoginCredentialAssertionView) => Promise; +} diff --git a/libs/common/src/auth/enums/authentication-type.ts b/libs/common/src/auth/enums/authentication-type.ts index 32751f40e5..23ca9ace76 100644 --- a/libs/common/src/auth/enums/authentication-type.ts +++ b/libs/common/src/auth/enums/authentication-type.ts @@ -3,4 +3,5 @@ export enum AuthenticationType { Sso = 1, UserApi = 2, AuthRequest = 3, + WebAuthn = 4, } diff --git a/libs/common/src/auth/login-strategies/login.strategy.ts b/libs/common/src/auth/login-strategies/login.strategy.ts index fb328c865c..74b2d9a762 100644 --- a/libs/common/src/auth/login-strategies/login.strategy.ts +++ b/libs/common/src/auth/login-strategies/login.strategy.ts @@ -24,12 +24,14 @@ import { PasswordLoginCredentials, SsoLoginCredentials, UserApiLoginCredentials, + WebAuthnLoginCredentials, } from "../models/domain/login-credentials"; import { DeviceRequest } from "../models/request/identity-token/device.request"; import { PasswordTokenRequest } from "../models/request/identity-token/password-token.request"; import { SsoTokenRequest } from "../models/request/identity-token/sso-token.request"; import { TokenTwoFactorRequest } from "../models/request/identity-token/token-two-factor.request"; import { UserApiTokenRequest } from "../models/request/identity-token/user-api-token.request"; +import { WebAuthnLoginTokenRequest } from "../models/request/identity-token/webauthn-login-token.request"; import { IdentityCaptchaResponse } from "../models/response/identity-captcha.response"; import { IdentityTokenResponse } from "../models/response/identity-token.response"; import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response"; @@ -37,7 +39,11 @@ import { IdentityTwoFactorResponse } from "../models/response/identity-two-facto type IdentityResponse = IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse; export abstract class LoginStrategy { - protected abstract tokenRequest: UserApiTokenRequest | PasswordTokenRequest | SsoTokenRequest; + protected abstract tokenRequest: + | UserApiTokenRequest + | PasswordTokenRequest + | SsoTokenRequest + | WebAuthnLoginTokenRequest; protected captchaBypassToken: string = null; constructor( @@ -58,6 +64,7 @@ export abstract class LoginStrategy { | PasswordLoginCredentials | SsoLoginCredentials | AuthRequestLoginCredentials + | WebAuthnLoginCredentials ): Promise; async logInTwoFactor( diff --git a/libs/common/src/auth/login-strategies/webauthn-login.strategy.spec.ts b/libs/common/src/auth/login-strategies/webauthn-login.strategy.spec.ts new file mode 100644 index 0000000000..83de3b5d6a --- /dev/null +++ b/libs/common/src/auth/login-strategies/webauthn-login.strategy.spec.ts @@ -0,0 +1,333 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { ApiService } from "../../abstractions/api.service"; +import { AppIdService } from "../../platform/abstractions/app-id.service"; +import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { LogService } from "../../platform/abstractions/log.service"; +import { MessagingService } from "../../platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service"; +import { StateService } from "../../platform/abstractions/state.service"; +import { Utils } from "../../platform/misc/utils"; +import { + PrfKey, + SymmetricCryptoKey, + UserKey, +} from "../../platform/models/domain/symmetric-crypto-key"; +import { TokenService } from "../abstractions/token.service"; +import { TwoFactorService } from "../abstractions/two-factor.service"; +import { AuthResult } from "../models/domain/auth-result"; +import { WebAuthnLoginCredentials } from "../models/domain/login-credentials"; +import { IdentityTokenResponse } from "../models/response/identity-token.response"; +import { IUserDecryptionOptionsServerResponse } from "../models/response/user-decryption-options/user-decryption-options.response"; +import { WebAuthnLoginAssertionResponseRequest } from "../services/webauthn-login/request/webauthn-login-assertion-response.request"; + +import { identityTokenResponseFactory } from "./login.strategy.spec"; +import { WebAuthnLoginStrategy } from "./webauthn-login.strategy"; + +describe("WebAuthnLoginStrategy", () => { + let cryptoService!: MockProxy; + let apiService!: MockProxy; + let tokenService!: MockProxy; + let appIdService!: MockProxy; + let platformUtilsService!: MockProxy; + let messagingService!: MockProxy; + let logService!: MockProxy; + let stateService!: MockProxy; + let twoFactorService!: MockProxy; + + let webAuthnLoginStrategy!: WebAuthnLoginStrategy; + + const token = "mockToken"; + const deviceId = Utils.newGuid(); + + let webAuthnCredentials!: WebAuthnLoginCredentials; + + let originalPublicKeyCredential!: PublicKeyCredential | any; + let originalAuthenticatorAssertionResponse!: AuthenticatorAssertionResponse | any; + + beforeAll(() => { + // Save off the original classes so we can restore them after all tests are done if they exist + originalPublicKeyCredential = global.PublicKeyCredential; + originalAuthenticatorAssertionResponse = global.AuthenticatorAssertionResponse; + + // We must do this to make the mocked classes available for all the + // assertCredential(...) tests. + global.PublicKeyCredential = MockPublicKeyCredential; + global.AuthenticatorAssertionResponse = MockAuthenticatorAssertionResponse; + }); + + beforeEach(() => { + jest.clearAllMocks(); + + cryptoService = mock(); + apiService = mock(); + tokenService = mock(); + appIdService = mock(); + platformUtilsService = mock(); + messagingService = mock(); + logService = mock(); + stateService = mock(); + twoFactorService = mock(); + + tokenService.getTwoFactorToken.mockResolvedValue(null); + appIdService.getAppId.mockResolvedValue(deviceId); + tokenService.decodeToken.mockResolvedValue({}); + + webAuthnLoginStrategy = new WebAuthnLoginStrategy( + cryptoService, + apiService, + tokenService, + appIdService, + platformUtilsService, + messagingService, + logService, + stateService, + twoFactorService + ); + + // Create credentials + const publicKeyCredential = new MockPublicKeyCredential(); + const deviceResponse = new WebAuthnLoginAssertionResponseRequest(publicKeyCredential); + const prfKey = new SymmetricCryptoKey(randomBytes(32)) as PrfKey; + webAuthnCredentials = new WebAuthnLoginCredentials(token, deviceResponse, prfKey); + }); + + afterAll(() => { + // Restore global after all tests are done + global.PublicKeyCredential = originalPublicKeyCredential; + global.AuthenticatorAssertionResponse = originalAuthenticatorAssertionResponse; + }); + + const mockEncPrfPrivateKey = + "2.eh465OrUcluL9UpnCOUTAg==|2HXNXwrLwAjUfZ/U75c92rZEltt1eHxjMkp/ADAmx346oT1+GaQvaL1QIV/9Om0T72m8AnlO92iUfWdhbA/ifHZ+lhFoUVeyw1M88CMzktbVcq42rFoK7SGHSAGdTL3ccUWKI8yCCQJhpt2X6a/5+T7ey5k2CqvylKyOtkiCnVeLmYqETn5BM9Rl3tEgJW1yDLuSJ+L+Qh9xnk/Z3zJUV5HAs+YwjKwuSNrd00SXjDyx8rBEstD9MKI+lrk7to/q90vqKqCucAj/dzUpVtHe88al2AAlBVwQ13HUPdNFOyti6niUgCAWx+DzRqlhkFvl/z/rtxtQsyqq/3Eh/EL54ylxKzAya0ev9EaIOm/dD1aBmI58p4Bs0eMOCIKJjtw+Cmdql+RhCtKtumgFShqyXv+LfD/FgUsdTVNExk3YNhgwPR4jOaMa/j9LCrBMCLKxdAhQyBe7T3qoX1fBBirvY6t77ifMu1YEQ6DfmFphVSwDH5C9xGeTSh5IELSf0tGVtlWUe9RffDDzccD0L1lR8U+dqzoSTYCuXvhEhQptdIW6fpH/47u0M5MiI97/d35A7Et2I1gjHp7WF3qsY20ellBueu7ZL5P1BmqPXl58yaBBXJaCutYHDfIucspqdZmfBGEbdRT4wmuZRON0J8zLmUejM0VR/2MOmpfyYQXnJhTfrvnZ1bOg1aMhUxJ2vhDNPXUFm5b+vwsho4GEvcLAKq9WwbvOJ/sK7sEVfTfEO2IG+0X6wkWm7RpR6Wq9FGKSrv2PSjMAYnb+z3ETeWiaaiD+tVFxa2AaqsbOuX092/86GySpHES7cFWhQ/YMOgj6egUi8mEC0CqMXYsx0TTJDsn16oP+XB3a2WoRqzE0YBozp2aMXxhVf/jMZ03BmEmRQu5B+Sq1gMEZwtIfJ+srkZLMYlLjvVw92FRoFy+N6ytPiyf6RMHMUnJ3vEZSBogaElYoQAtFJ5kK811CUzb78zEHH8xWtPrCZn9zZfvf/zaWxo7fpV8VwAwUeHXHcQMraZum5QeO+5tLRUYrLm85JNelGfmUA3BjfNyFbfb32PhkWWd0CbDaPME48uIriVK32pNEtvtR/+I/f3YgA/jP9kSlDvbzG/OAg/AFBIpNwKUzsu4+va8mI+O5FDufw5D74WwdGJ9DeyEb2CHtWMR1VwtFKL0ZZsqltNf8EkBeJ5RtTNtAMM8ie4dDZaKC96ymQHKrdB4hjkAr0F1XFsU4XdOa9Nbkdcm/7KoNc6bE6oJtG9lqE8h+1CysfcbfJ7am+hvDFzT0IPmp3GDSMAk+e6xySgFQw0C/SZ7LQsxPa1s6hc+BOtTn0oClZnU7Mowxv+z+xURJj4Yp3Cy6tAoia1jEQSs6lSMNKPf9bi3xFKtPl4143hwhpvTAzJUcski9OVGd7Du+VyxwIrvLqp5Ct/oNrESVJpf1EDCs9xT1EW+PiSkRmHXoZ1t5MOLFEiMAZL2+bNe3A2661oJeMtps8zrfCVc251OUE1WvqWePlTOs5TDVqdwDH88J6rHLsbaf33Mxh5DP8gMfZQxE44Nsp6H0/Szfkss5UmFwBEpHjl1GJMWDnB3u2d+l1CSkLoB6C+diAUlY6wL/VwJBeMPHZTf6amQIS2B/lo/CnvV/E3k=|uuoY4b7xwMYBNIZi85KBsaHmNqtJl5FrKxZI9ugeNwc="; + + const mockEncUserKey = + "4.Xht6K9GA9jKcSNy4TaIvdj7f9+WsgQycs/HdkrJi33aC//roKkjf3UTGpdzFLxVP3WhyOVGyo9f2Jymf1MFPdpg7AuMnpGJlcrWLDbnPjOJo4x5gUwwBUmy3nFw6+wamyS1LRmrBPcv56yKpf80k5Q3hUrum8q9YS9m2I10vklX/TaB1YML0yo+K1feWUxg8vIx+vloxhUdkkysvcV5xU3R+AgYLrwvJS8TLL7Ug/P5HxinCaIroRrNe8xcv84vyVnzPFdXe0cfZ0cpcrm586LwfEXP2seeldO/bC51Uk/mudeSALJURPC64f5ch2cOvk48GOTapGnssCqr6ky5yFw=="; + + const userDecryptionOptsServerResponseWithWebAuthnPrfOption: IUserDecryptionOptionsServerResponse = + { + HasMasterPassword: true, + WebAuthnPrfOption: { + EncryptedPrivateKey: mockEncPrfPrivateKey, + EncryptedUserKey: mockEncUserKey, + }, + }; + + const mockIdTokenResponseWithModifiedWebAuthnPrfOption = (key: string, value: any) => { + const userDecryptionOpts: IUserDecryptionOptionsServerResponse = { + ...userDecryptionOptsServerResponseWithWebAuthnPrfOption, + WebAuthnPrfOption: { + ...userDecryptionOptsServerResponseWithWebAuthnPrfOption.WebAuthnPrfOption, + [key]: value, + }, + }; + return identityTokenResponseFactory(null, userDecryptionOpts); + }; + + it("returns successful authResult when api service returns valid credentials", async () => { + // Arrange + const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory( + null, + userDecryptionOptsServerResponseWithWebAuthnPrfOption + ); + + apiService.postIdentityToken.mockResolvedValue(idTokenResponse); + + // Act + const authResult = await webAuthnLoginStrategy.logIn(webAuthnCredentials); + + // Assert + expect(apiService.postIdentityToken).toHaveBeenCalledWith( + expect.objectContaining({ + // webauthn specific info + token: webAuthnCredentials.token, + deviceResponse: webAuthnCredentials.deviceResponse, + // standard info + device: expect.objectContaining({ + identifier: deviceId, + }), + }) + ); + + expect(authResult).toBeInstanceOf(AuthResult); + expect(authResult).toMatchObject({ + captchaSiteKey: "", + forcePasswordReset: 0, + resetMasterPassword: false, + twoFactorProviders: null, + requiresTwoFactor: false, + requiresCaptcha: false, + }); + }); + + it("decrypts and sets user key when webAuthn PRF decryption option exists with valid PRF key and enc key data", async () => { + // Arrange + const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory( + null, + userDecryptionOptsServerResponseWithWebAuthnPrfOption + ); + + apiService.postIdentityToken.mockResolvedValue(idTokenResponse); + + const mockPrfPrivateKey: Uint8Array = randomBytes(32); + const mockUserKeyArray: Uint8Array = randomBytes(32); + const mockUserKey = new SymmetricCryptoKey(mockUserKeyArray) as UserKey; + + cryptoService.decryptToBytes.mockResolvedValue(mockPrfPrivateKey); + cryptoService.rsaDecrypt.mockResolvedValue(mockUserKeyArray); + + // Act + await webAuthnLoginStrategy.logIn(webAuthnCredentials); + + // // Assert + expect(cryptoService.decryptToBytes).toHaveBeenCalledTimes(1); + expect(cryptoService.decryptToBytes).toHaveBeenCalledWith( + idTokenResponse.userDecryptionOptions.webAuthnPrfOption.encryptedPrivateKey, + webAuthnCredentials.prfKey + ); + expect(cryptoService.rsaDecrypt).toHaveBeenCalledTimes(1); + expect(cryptoService.rsaDecrypt).toHaveBeenCalledWith( + idTokenResponse.userDecryptionOptions.webAuthnPrfOption.encryptedUserKey.encryptedString, + mockPrfPrivateKey + ); + expect(cryptoService.setUserKey).toHaveBeenCalledWith(mockUserKey); + expect(cryptoService.setPrivateKey).toHaveBeenCalledWith(idTokenResponse.privateKey); + + // Master key and private key should not be set + expect(cryptoService.setMasterKey).not.toHaveBeenCalled(); + }); + + it("does not try to set the user key when prfKey is missing", async () => { + // Arrange + const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory( + null, + userDecryptionOptsServerResponseWithWebAuthnPrfOption + ); + + apiService.postIdentityToken.mockResolvedValue(idTokenResponse); + + // Remove PRF key + webAuthnCredentials.prfKey = null; + + // Act + await webAuthnLoginStrategy.logIn(webAuthnCredentials); + + // Assert + expect(cryptoService.decryptToBytes).not.toHaveBeenCalled(); + expect(cryptoService.rsaDecrypt).not.toHaveBeenCalled(); + expect(cryptoService.setUserKey).not.toHaveBeenCalled(); + }); + + describe.each([ + { + valueName: "encPrfPrivateKey", + }, + { + valueName: "encUserKey", + }, + ])("given webAuthn PRF decryption option has missing encrypted key data", ({ valueName }) => { + it(`does not set the user key when ${valueName} is missing`, async () => { + // Arrange + const idTokenResponse = mockIdTokenResponseWithModifiedWebAuthnPrfOption(valueName, null); + apiService.postIdentityToken.mockResolvedValue(idTokenResponse); + + // Act + await webAuthnLoginStrategy.logIn(webAuthnCredentials); + + // Assert + expect(cryptoService.setUserKey).not.toHaveBeenCalled(); + }); + }); + + it("does not set the user key when the PRF encrypted private key decryption fails", async () => { + // Arrange + const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory( + null, + userDecryptionOptsServerResponseWithWebAuthnPrfOption + ); + + apiService.postIdentityToken.mockResolvedValue(idTokenResponse); + + cryptoService.decryptToBytes.mockResolvedValue(null); + + // Act + await webAuthnLoginStrategy.logIn(webAuthnCredentials); + + // Assert + expect(cryptoService.setUserKey).not.toHaveBeenCalled(); + }); + + it("does not set the user key when the encrypted user key decryption fails", async () => { + // Arrange + const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory( + null, + userDecryptionOptsServerResponseWithWebAuthnPrfOption + ); + + apiService.postIdentityToken.mockResolvedValue(idTokenResponse); + + cryptoService.rsaDecrypt.mockResolvedValue(null); + + // Act + await webAuthnLoginStrategy.logIn(webAuthnCredentials); + + // Assert + expect(cryptoService.setUserKey).not.toHaveBeenCalled(); + }); +}); + +// Helpers and mocks +function randomBytes(length: number): Uint8Array { + return new Uint8Array(Array.from({ length }, (_, k) => k % 255)); +} + +// AuthenticatorAssertionResponse && PublicKeyCredential are only available in secure contexts +// so we need to mock them and assign them to the global object to make them available +// for the tests +class MockAuthenticatorAssertionResponse implements AuthenticatorAssertionResponse { + clientDataJSON: ArrayBuffer = randomBytes(32).buffer; + authenticatorData: ArrayBuffer = randomBytes(196).buffer; + signature: ArrayBuffer = randomBytes(72).buffer; + userHandle: ArrayBuffer = randomBytes(16).buffer; + + clientDataJSONB64Str = Utils.fromBufferToUrlB64(this.clientDataJSON); + authenticatorDataB64Str = Utils.fromBufferToUrlB64(this.authenticatorData); + signatureB64Str = Utils.fromBufferToUrlB64(this.signature); + userHandleB64Str = Utils.fromBufferToUrlB64(this.userHandle); +} + +class MockPublicKeyCredential implements PublicKeyCredential { + authenticatorAttachment = "cross-platform"; + id = "mockCredentialId"; + type = "public-key"; + rawId: ArrayBuffer = randomBytes(32).buffer; + rawIdB64Str = Utils.fromBufferToB64(this.rawId); + + response: MockAuthenticatorAssertionResponse = new MockAuthenticatorAssertionResponse(); + + // Use random 64 character hex string (32 bytes - matters for symmetric key creation) + // to represent the prf key binary data and convert to ArrayBuffer + // Creating the array buffer from a known hex value allows us to + // assert on the value in tests + private prfKeyArrayBuffer: ArrayBuffer = Utils.hexStringToArrayBuffer( + "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + ); + + getClientExtensionResults(): any { + return { + prf: { + results: { + first: this.prfKeyArrayBuffer, + }, + }, + }; + } + + static isConditionalMediationAvailable(): Promise { + return Promise.resolve(false); + } + + static isUserVerifyingPlatformAuthenticatorAvailable(): Promise { + return Promise.resolve(false); + } +} diff --git a/libs/common/src/auth/login-strategies/webauthn-login.strategy.ts b/libs/common/src/auth/login-strategies/webauthn-login.strategy.ts new file mode 100644 index 0000000000..8d47be0197 --- /dev/null +++ b/libs/common/src/auth/login-strategies/webauthn-login.strategy.ts @@ -0,0 +1,68 @@ +import { SymmetricCryptoKey, UserKey } from "../../platform/models/domain/symmetric-crypto-key"; +import { AuthResult } from "../models/domain/auth-result"; +import { WebAuthnLoginCredentials } from "../models/domain/login-credentials"; +import { WebAuthnLoginTokenRequest } from "../models/request/identity-token/webauthn-login-token.request"; +import { IdentityTokenResponse } from "../models/response/identity-token.response"; + +import { LoginStrategy } from "./login.strategy"; + +export class WebAuthnLoginStrategy extends LoginStrategy { + tokenRequest: WebAuthnLoginTokenRequest; + private credentials: WebAuthnLoginCredentials; + + protected override async setMasterKey() { + return Promise.resolve(); + } + + protected override async setUserKey(idTokenResponse: IdentityTokenResponse) { + const userDecryptionOptions = idTokenResponse?.userDecryptionOptions; + + if (userDecryptionOptions?.webAuthnPrfOption) { + const webAuthnPrfOption = idTokenResponse.userDecryptionOptions?.webAuthnPrfOption; + + // confirm we still have the prf key + if (!this.credentials.prfKey) { + return; + } + + // decrypt prf encrypted private key + const privateKey = await this.cryptoService.decryptToBytes( + webAuthnPrfOption.encryptedPrivateKey, + this.credentials.prfKey + ); + + // decrypt user key with private key + const userKey = await this.cryptoService.rsaDecrypt( + webAuthnPrfOption.encryptedUserKey.encryptedString, + privateKey + ); + + if (userKey) { + await this.cryptoService.setUserKey(new SymmetricCryptoKey(userKey) as UserKey); + } + } + } + + protected override async setPrivateKey(response: IdentityTokenResponse): Promise { + await this.cryptoService.setPrivateKey( + response.privateKey ?? (await this.createKeyPairForOldAccount()) + ); + } + + async logInTwoFactor(): Promise { + throw new Error("2FA not supported yet for WebAuthn Login."); + } + + async logIn(credentials: WebAuthnLoginCredentials) { + this.credentials = credentials; + + this.tokenRequest = new WebAuthnLoginTokenRequest( + credentials.token, + credentials.deviceResponse, + await this.buildDeviceRequest() + ); + + const [authResult] = await this.startLogIn(); + return authResult; + } +} diff --git a/libs/common/src/auth/models/domain/login-credentials.ts b/libs/common/src/auth/models/domain/login-credentials.ts index a2a7016442..2e3e4916d0 100644 --- a/libs/common/src/auth/models/domain/login-credentials.ts +++ b/libs/common/src/auth/models/domain/login-credentials.ts @@ -1,5 +1,10 @@ -import { MasterKey, UserKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { + MasterKey, + UserKey, + SymmetricCryptoKey, +} from "../../../platform/models/domain/symmetric-crypto-key"; import { AuthenticationType } from "../../enums/authentication-type"; +import { WebAuthnLoginAssertionResponseRequest } from "../../services/webauthn-login/request/webauthn-login-assertion-response.request"; import { TokenTwoFactorRequest } from "../request/identity-token/token-two-factor.request"; export class PasswordLoginCredentials { @@ -44,3 +49,13 @@ export class AuthRequestLoginCredentials { public twoFactor?: TokenTwoFactorRequest ) {} } + +export class WebAuthnLoginCredentials { + readonly type = AuthenticationType.WebAuthn; + + constructor( + public token: string, + public deviceResponse: WebAuthnLoginAssertionResponseRequest, + public prfKey?: SymmetricCryptoKey + ) {} +} diff --git a/libs/common/src/auth/models/request/identity-token/token.request.ts b/libs/common/src/auth/models/request/identity-token/token.request.ts index 5195eedbdf..653f7fe5c3 100644 --- a/libs/common/src/auth/models/request/identity-token/token.request.ts +++ b/libs/common/src/auth/models/request/identity-token/token.request.ts @@ -5,7 +5,7 @@ export abstract class TokenRequest { protected device?: DeviceRequest; protected authRequest: string; - constructor(protected twoFactor: TokenTwoFactorRequest, device?: DeviceRequest) { + constructor(protected twoFactor?: TokenTwoFactorRequest, device?: DeviceRequest) { this.device = device != null ? device : null; } @@ -14,7 +14,7 @@ export abstract class TokenRequest { // Implemented in subclass if required } - setTwoFactor(twoFactor: TokenTwoFactorRequest) { + setTwoFactor(twoFactor: TokenTwoFactorRequest | undefined) { this.twoFactor = twoFactor; } diff --git a/libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts b/libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts new file mode 100644 index 0000000000..75c6cf4c27 --- /dev/null +++ b/libs/common/src/auth/models/request/identity-token/webauthn-login-token.request.ts @@ -0,0 +1,25 @@ +import { WebAuthnLoginAssertionResponseRequest } from "../../../services/webauthn-login/request/webauthn-login-assertion-response.request"; + +import { DeviceRequest } from "./device.request"; +import { TokenRequest } from "./token.request"; + +export class WebAuthnLoginTokenRequest extends TokenRequest { + constructor( + public token: string, + public deviceResponse: WebAuthnLoginAssertionResponseRequest, + device?: DeviceRequest + ) { + super(undefined, device); + } + + toIdentityToken(clientId: string) { + const obj = super.toIdentityToken(clientId); + + obj.grant_type = "webauthn"; + obj.token = this.token; + // must be a string b/c sending as form encoded data + obj.deviceResponse = JSON.stringify(this.deviceResponse); + + return obj; + } +} diff --git a/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts b/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts index fcf1f49ace..466b3cca70 100644 --- a/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts +++ b/libs/common/src/auth/models/response/user-decryption-options/user-decryption-options.response.ts @@ -8,17 +8,23 @@ import { ITrustedDeviceUserDecryptionOptionServerResponse, TrustedDeviceUserDecryptionOptionResponse, } from "./trusted-device-user-decryption-option.response"; +import { + IWebAuthnPrfDecryptionOptionServerResponse, + WebAuthnPrfDecryptionOptionResponse, +} from "./webauthn-prf-decryption-option.response"; export interface IUserDecryptionOptionsServerResponse { HasMasterPassword: boolean; TrustedDeviceOption?: ITrustedDeviceUserDecryptionOptionServerResponse; KeyConnectorOption?: IKeyConnectorUserDecryptionOptionServerResponse; + WebAuthnPrfOption?: IWebAuthnPrfDecryptionOptionServerResponse; } export class UserDecryptionOptionsResponse extends BaseResponse { hasMasterPassword: boolean; trustedDeviceOption?: TrustedDeviceUserDecryptionOptionResponse; keyConnectorOption?: KeyConnectorUserDecryptionOptionResponse; + webAuthnPrfOption?: WebAuthnPrfDecryptionOptionResponse; constructor(response: IUserDecryptionOptionsServerResponse) { super(response); @@ -35,5 +41,10 @@ export class UserDecryptionOptionsResponse extends BaseResponse { this.getResponseProperty("KeyConnectorOption") ); } + if (response.WebAuthnPrfOption) { + this.webAuthnPrfOption = new WebAuthnPrfDecryptionOptionResponse( + this.getResponseProperty("WebAuthnPrfOption") + ); + } } } diff --git a/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts b/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts new file mode 100644 index 0000000000..777b3dffc4 --- /dev/null +++ b/libs/common/src/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response.ts @@ -0,0 +1,22 @@ +import { BaseResponse } from "../../../../models/response/base.response"; +import { EncString } from "../../../../platform/models/domain/enc-string"; + +export interface IWebAuthnPrfDecryptionOptionServerResponse { + EncryptedPrivateKey: string; + EncryptedUserKey: string; +} + +export class WebAuthnPrfDecryptionOptionResponse extends BaseResponse { + encryptedPrivateKey: EncString; + encryptedUserKey: EncString; + + constructor(response: IWebAuthnPrfDecryptionOptionServerResponse) { + super(response); + if (response.EncryptedPrivateKey) { + this.encryptedPrivateKey = new EncString(this.getResponseProperty("EncryptedPrivateKey")); + } + if (response.EncryptedUserKey) { + this.encryptedUserKey = new EncString(this.getResponseProperty("EncryptedUserKey")); + } + } +} diff --git a/libs/common/src/auth/models/view/webauthn-login/webauthn-login-credential-assertion-options.view.ts b/libs/common/src/auth/models/view/webauthn-login/webauthn-login-credential-assertion-options.view.ts new file mode 100644 index 0000000000..4b49e979b1 --- /dev/null +++ b/libs/common/src/auth/models/view/webauthn-login/webauthn-login-credential-assertion-options.view.ts @@ -0,0 +1,5 @@ +import { AssertionOptionsResponse } from "../../../services/webauthn-login/response/assertion-options.response"; + +export class WebAuthnLoginCredentialAssertionOptionsView { + constructor(readonly options: AssertionOptionsResponse, readonly token: string) {} +} diff --git a/libs/common/src/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view.ts b/libs/common/src/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view.ts new file mode 100644 index 0000000000..bb36a62e5f --- /dev/null +++ b/libs/common/src/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view.ts @@ -0,0 +1,10 @@ +import { PrfKey } from "../../../../platform/models/domain/symmetric-crypto-key"; +import { WebAuthnLoginAssertionResponseRequest } from "../../../services/webauthn-login/request/webauthn-login-assertion-response.request"; + +export class WebAuthnLoginCredentialAssertionView { + constructor( + readonly token: string, + readonly deviceResponse: WebAuthnLoginAssertionResponseRequest, + readonly prfKey?: PrfKey + ) {} +} diff --git a/libs/common/src/auth/services/auth.service.ts b/libs/common/src/auth/services/auth.service.ts index 2e4862b409..1c5f066d3a 100644 --- a/libs/common/src/auth/services/auth.service.ts +++ b/libs/common/src/auth/services/auth.service.ts @@ -30,6 +30,7 @@ import { AuthRequestLoginStrategy } from "../login-strategies/auth-request-login import { PasswordLoginStrategy } from "../login-strategies/password-login.strategy"; import { SsoLoginStrategy } from "../login-strategies/sso-login.strategy"; import { UserApiLoginStrategy } from "../login-strategies/user-api-login.strategy"; +import { WebAuthnLoginStrategy } from "../login-strategies/webauthn-login.strategy"; import { AuthResult } from "../models/domain/auth-result"; import { KdfConfig } from "../models/domain/kdf-config"; import { @@ -37,6 +38,7 @@ import { PasswordLoginCredentials, SsoLoginCredentials, UserApiLoginCredentials, + WebAuthnLoginCredentials, } from "../models/domain/login-credentials"; import { TokenTwoFactorRequest } from "../models/request/identity-token/token-two-factor.request"; import { PasswordlessAuthRequest } from "../models/request/passwordless-auth.request"; @@ -85,7 +87,8 @@ export class AuthService implements AuthServiceAbstraction { | UserApiLoginStrategy | PasswordLoginStrategy | SsoLoginStrategy - | AuthRequestLoginStrategy; + | AuthRequestLoginStrategy + | WebAuthnLoginStrategy; private sessionTimeout: any; private pushNotificationSubject = new Subject(); @@ -116,6 +119,7 @@ export class AuthService implements AuthServiceAbstraction { | PasswordLoginCredentials | SsoLoginCredentials | AuthRequestLoginCredentials + | WebAuthnLoginCredentials ): Promise { this.clearState(); @@ -123,7 +127,8 @@ export class AuthService implements AuthServiceAbstraction { | UserApiLoginStrategy | PasswordLoginStrategy | SsoLoginStrategy - | AuthRequestLoginStrategy; + | AuthRequestLoginStrategy + | WebAuthnLoginStrategy; switch (credentials.type) { case AuthenticationType.Password: @@ -188,6 +193,19 @@ export class AuthService implements AuthServiceAbstraction { this.deviceTrustCryptoService ); break; + case AuthenticationType.WebAuthn: + strategy = new WebAuthnLoginStrategy( + this.cryptoService, + this.apiService, + this.tokenService, + this.appIdService, + this.platformUtilsService, + this.messagingService, + this.logService, + this.stateService, + this.twoFactorService + ); + break; } const result = await strategy.logIn(credentials as any); @@ -353,6 +371,7 @@ export class AuthService implements AuthServiceAbstraction { | PasswordLoginStrategy | SsoLoginStrategy | AuthRequestLoginStrategy + | WebAuthnLoginStrategy ) { this.logInStrategy = strategy; this.startSessionTimeout(); diff --git a/libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts b/libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts new file mode 100644 index 0000000000..2f7eddb2d6 --- /dev/null +++ b/libs/common/src/auth/services/webauthn-login/request/webauthn-login-assertion-response.request.ts @@ -0,0 +1,30 @@ +import { Utils } from "../../../../platform/misc/utils"; + +import { WebAuthnLoginResponseRequest } from "./webauthn-login-response.request"; + +// base 64 strings +export interface WebAuthnLoginAssertionResponseData { + authenticatorData: string; + signature: string; + clientDataJSON: string; + userHandle: string; +} + +export class WebAuthnLoginAssertionResponseRequest extends WebAuthnLoginResponseRequest { + response: WebAuthnLoginAssertionResponseData; + + constructor(credential: PublicKeyCredential) { + super(credential); + + if (!(credential.response instanceof AuthenticatorAssertionResponse)) { + throw new Error("Invalid authenticator response"); + } + + this.response = { + authenticatorData: Utils.fromBufferToUrlB64(credential.response.authenticatorData), + signature: Utils.fromBufferToUrlB64(credential.response.signature), + clientDataJSON: Utils.fromBufferToUrlB64(credential.response.clientDataJSON), + userHandle: Utils.fromBufferToUrlB64(credential.response.userHandle), + }; + } +} diff --git a/libs/common/src/auth/services/webauthn-login/request/webauthn-login-response.request.ts b/libs/common/src/auth/services/webauthn-login/request/webauthn-login-response.request.ts new file mode 100644 index 0000000000..4b1eff4d47 --- /dev/null +++ b/libs/common/src/auth/services/webauthn-login/request/webauthn-login-response.request.ts @@ -0,0 +1,19 @@ +import { Utils } from "../../../../platform/misc/utils"; + +export abstract class WebAuthnLoginResponseRequest { + id: string; + rawId: string; + type: string; + extensions: Record; + + constructor(credential: PublicKeyCredential) { + this.id = credential.id; + this.rawId = Utils.fromBufferToUrlB64(credential.rawId); + this.type = credential.type; + + // WARNING: do not add PRF information here by mapping + // credential.getClientExtensionResults() into the extensions property, + // as it will be sent to the server (leaking credentials). + this.extensions = {}; // Extensions are handled client-side + } +} diff --git a/libs/common/src/auth/services/webauthn-login/response/assertion-options.response.ts b/libs/common/src/auth/services/webauthn-login/response/assertion-options.response.ts new file mode 100644 index 0000000000..b7ac7354f2 --- /dev/null +++ b/libs/common/src/auth/services/webauthn-login/response/assertion-options.response.ts @@ -0,0 +1,28 @@ +import { BaseResponse } from "../../../../models/response/base.response"; +import { Utils } from "../../../../platform/misc/utils"; + +export class AssertionOptionsResponse + extends BaseResponse + implements PublicKeyCredentialRequestOptions +{ + /** A list of credentials that the authenticator is allowed to use; only used for non-discoverable flow */ + allowCredentials?: PublicKeyCredentialDescriptor[]; + challenge: BufferSource; + extensions?: AuthenticationExtensionsClientInputs; + rpId?: string; + timeout?: number; + userVerification?: UserVerificationRequirement; + + constructor(response: unknown) { + super(response); + this.allowCredentials = this.getResponseProperty("allowCredentials")?.map((c: any) => ({ + ...c, + id: Utils.fromUrlB64ToArray(c.id).buffer, + })); + this.challenge = Utils.fromUrlB64ToArray(this.getResponseProperty("challenge")); + this.extensions = this.getResponseProperty("extensions"); + this.rpId = this.getResponseProperty("rpId"); + this.timeout = this.getResponseProperty("timeout"); + this.userVerification = this.getResponseProperty("userVerification"); + } +} diff --git a/libs/common/src/auth/services/webauthn-login/response/credential-assertion-options.response.ts b/libs/common/src/auth/services/webauthn-login/response/credential-assertion-options.response.ts new file mode 100644 index 0000000000..f0b1d27c12 --- /dev/null +++ b/libs/common/src/auth/services/webauthn-login/response/credential-assertion-options.response.ts @@ -0,0 +1,14 @@ +import { BaseResponse } from "../../../../models/response/base.response"; + +import { AssertionOptionsResponse } from "./assertion-options.response"; + +export class CredentialAssertionOptionsResponse extends BaseResponse { + options: AssertionOptionsResponse; + token: string; + + constructor(response: unknown) { + super(response); + this.options = new AssertionOptionsResponse(this.getResponseProperty("options")); + this.token = this.getResponseProperty("token"); + } +} diff --git a/libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts b/libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts new file mode 100644 index 0000000000..a10f518881 --- /dev/null +++ b/libs/common/src/auth/services/webauthn-login/webauthn-login-api.service.ts @@ -0,0 +1,21 @@ +import { ApiService } from "../../../abstractions/api.service"; +import { EnvironmentService } from "../../../platform/abstractions/environment.service"; +import { WebAuthnLoginApiServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-api.service.abstraction"; + +import { CredentialAssertionOptionsResponse } from "./response/credential-assertion-options.response"; + +export class WebAuthnLoginApiService implements WebAuthnLoginApiServiceAbstraction { + constructor(private apiService: ApiService, private environmentService: EnvironmentService) {} + + async getCredentialAssertionOptions(): Promise { + const response = await this.apiService.send( + "GET", + `/accounts/webauthn/assertion-options`, + null, + false, + true, + this.environmentService.getIdentityUrl() + ); + return new CredentialAssertionOptionsResponse(response); + } +} diff --git a/libs/common/src/auth/services/webauthn-login/webauthn-login-prf-crypto.service.spec.ts b/libs/common/src/auth/services/webauthn-login/webauthn-login-prf-crypto.service.spec.ts new file mode 100644 index 0000000000..7bfb36d769 --- /dev/null +++ b/libs/common/src/auth/services/webauthn-login/webauthn-login-prf-crypto.service.spec.ts @@ -0,0 +1,32 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { CryptoFunctionService } from "../../../platform/abstractions/crypto-function.service"; + +import { WebAuthnLoginPrfCryptoService } from "./webauthn-login-prf-crypto.service"; + +describe("WebAuthnLoginPrfCryptoService", () => { + let cryptoFunctionService: MockProxy; + let service: WebAuthnLoginPrfCryptoService; + + beforeEach(() => { + cryptoFunctionService = mock(); + service = new WebAuthnLoginPrfCryptoService(cryptoFunctionService); + }); + + describe("createSymmetricKeyFromPrf", () => { + it("should stretch the key to 64 bytes when given a key with 32 bytes", async () => { + cryptoFunctionService.hkdfExpand.mockImplementation((key, salt, length) => + Promise.resolve(randomBytes(length)) + ); + + const result = await service.createSymmetricKeyFromPrf(randomBytes(32)); + + expect(result.key.length).toBe(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/auth/services/webauthn-login/webauthn-login-prf-crypto.service.ts b/libs/common/src/auth/services/webauthn-login/webauthn-login-prf-crypto.service.ts new file mode 100644 index 0000000000..4962f10010 --- /dev/null +++ b/libs/common/src/auth/services/webauthn-login/webauthn-login-prf-crypto.service.ts @@ -0,0 +1,26 @@ +import { CryptoFunctionService } from "../../../platform/abstractions/crypto-function.service"; +import { PrfKey, SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { WebAuthnLoginPrfCryptoServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction"; + +const LoginWithPrfSalt = "passwordless-login"; + +export class WebAuthnLoginPrfCryptoService implements WebAuthnLoginPrfCryptoServiceAbstraction { + constructor(private cryptoFunctionService: CryptoFunctionService) {} + + async getLoginWithPrfSalt(): Promise { + return await this.cryptoFunctionService.hash(LoginWithPrfSalt, "sha256"); + } + + async createSymmetricKeyFromPrf(prf: ArrayBuffer): Promise { + return (await this.stretchKey(new Uint8Array(prf))) as PrfKey; + } + + private async stretchKey(key: Uint8Array): Promise { + const newKey = new Uint8Array(64); + const encKey = await this.cryptoFunctionService.hkdfExpand(key, "enc", 32, "sha256"); + const macKey = await this.cryptoFunctionService.hkdfExpand(key, "mac", 32, "sha256"); + newKey.set(new Uint8Array(encKey)); + newKey.set(new Uint8Array(macKey), 32); + return new SymmetricCryptoKey(newKey); + } +} diff --git a/libs/common/src/auth/services/webauthn-login/webauthn-login.service.spec.ts b/libs/common/src/auth/services/webauthn-login/webauthn-login.service.spec.ts new file mode 100644 index 0000000000..4b8e73abb2 --- /dev/null +++ b/libs/common/src/auth/services/webauthn-login/webauthn-login.service.spec.ts @@ -0,0 +1,377 @@ +import { mock } from "jest-mock-extended"; +import { firstValueFrom, of } from "rxjs"; + +import { ConfigServiceAbstraction } from "../../../platform/abstractions/config/config.service.abstraction"; +import { LogService } from "../../../platform/abstractions/log.service"; +import { Utils } from "../../../platform/misc/utils"; +import { PrfKey, SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { AuthService } from "../../abstractions/auth.service"; +import { WebAuthnLoginApiServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-api.service.abstraction"; +import { WebAuthnLoginPrfCryptoServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction"; +import { AuthResult } from "../../models/domain/auth-result"; +import { WebAuthnLoginCredentials } from "../../models/domain/login-credentials"; +import { WebAuthnLoginCredentialAssertionOptionsView } from "../../models/view/webauthn-login/webauthn-login-credential-assertion-options.view"; +import { WebAuthnLoginCredentialAssertionView } from "../../models/view/webauthn-login/webauthn-login-credential-assertion.view"; + +import { WebAuthnLoginAssertionResponseRequest } from "./request/webauthn-login-assertion-response.request"; +import { CredentialAssertionOptionsResponse } from "./response/credential-assertion-options.response"; +import { WebAuthnLoginService } from "./webauthn-login.service"; + +describe("WebAuthnLoginService", () => { + let webAuthnLoginService: WebAuthnLoginService; + + const webAuthnLoginApiService = mock(); + const authService = mock(); + const configService = mock(); + const webAuthnLoginPrfCryptoService = mock(); + const navigatorCredentials = mock(); + const logService = mock(); + + let originalPublicKeyCredential!: PublicKeyCredential | any; + let originalAuthenticatorAssertionResponse!: AuthenticatorAssertionResponse | any; + let originalNavigator!: Navigator; + + beforeAll(() => { + // Save off the original classes so we can restore them after all tests are done if they exist + originalPublicKeyCredential = global.PublicKeyCredential; + originalAuthenticatorAssertionResponse = global.AuthenticatorAssertionResponse; + + // We must do this to make the mocked classes available for all the + // assertCredential(...) tests. + global.PublicKeyCredential = MockPublicKeyCredential; + global.AuthenticatorAssertionResponse = MockAuthenticatorAssertionResponse; + + // Save the original navigator + originalNavigator = global.window.navigator; + + // Mock the window.navigator with mocked CredentialsContainer + Object.defineProperty(global.window, "navigator", { + value: { + ...originalNavigator, + credentials: navigatorCredentials, + }, + configurable: true, + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + // Restore global after all tests are done + global.PublicKeyCredential = originalPublicKeyCredential; + global.AuthenticatorAssertionResponse = originalAuthenticatorAssertionResponse; + + // Restore the original navigator + Object.defineProperty(global.window, "navigator", { + value: originalNavigator, + configurable: true, + }); + }); + + function createWebAuthnLoginService(config: { featureEnabled: boolean }): WebAuthnLoginService { + configService.getFeatureFlag$.mockReturnValue(of(config.featureEnabled)); + return new WebAuthnLoginService( + webAuthnLoginApiService, + authService, + configService, + webAuthnLoginPrfCryptoService, + window, + logService + ); + } + + it("instantiates", () => { + webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: true }); + expect(webAuthnLoginService).not.toBeFalsy(); + }); + + describe("enabled$", () => { + it("should emit true when feature flag for PasswordlessLogin is enabled", async () => { + // Arrange + const webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: true }); + + // Act & Assert + const result = await firstValueFrom(webAuthnLoginService.enabled$); + expect(result).toBe(true); + }); + + it("should emit false when feature flag for PasswordlessLogin is disabled", async () => { + // Arrange + const webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: false }); + + // Act & Assert + const result = await firstValueFrom(webAuthnLoginService.enabled$); + expect(result).toBe(false); + }); + }); + + describe("getCredentialAssertionOptions()", () => { + it("webAuthnLoginService returns WebAuthnLoginCredentialAssertionOptionsView when getCredentialAssertionOptions is called with the feature enabled", async () => { + // Arrange + const webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: true }); + + const challenge = "6CG3jqMCVASJVXySMi9KWw"; + const token = "BWWebAuthnLoginAssertionOptions_CfDJ_2KBN892w"; + const timeout = 60000; + const rpId = "localhost"; + const allowCredentials = [] as PublicKeyCredentialDescriptor[]; + const userVerification = "required"; + const objectName = "webAuthnLoginAssertionOptions"; + + const mockedCredentialAssertionOptionsServerResponse = { + options: { + challenge: challenge, + timeout: timeout, + rpId: rpId, + allowCredentials: allowCredentials, + userVerification: userVerification, + status: "ok", + errorMessage: "", + }, + token: token, + object: objectName, + }; + + const mockedCredentialAssertionOptionsResponse = new CredentialAssertionOptionsResponse( + mockedCredentialAssertionOptionsServerResponse + ); + + webAuthnLoginApiService.getCredentialAssertionOptions.mockResolvedValue( + mockedCredentialAssertionOptionsResponse + ); + + // Act + const result = await webAuthnLoginService.getCredentialAssertionOptions(); + + // Assert + expect(result).toBeInstanceOf(WebAuthnLoginCredentialAssertionOptionsView); + }); + }); + + describe("assertCredential(...)", () => { + it("should assert the credential and return WebAuthnLoginAssertionView on success", async () => { + // Arrange + const webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: true }); + const credentialAssertionOptions = buildCredentialAssertionOptions(); + + // Mock webAuthnUtils functions + const expectedSaltHex = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"; + const saltArrayBuffer = Utils.hexStringToArrayBuffer(expectedSaltHex); + + const publicKeyCredential = new MockPublicKeyCredential(); + const prfResult: ArrayBuffer = + publicKeyCredential.getClientExtensionResults().prf?.results?.first; + const prfKey = new SymmetricCryptoKey(new Uint8Array(prfResult)) as PrfKey; + + webAuthnLoginPrfCryptoService.getLoginWithPrfSalt.mockResolvedValue(saltArrayBuffer); + webAuthnLoginPrfCryptoService.createSymmetricKeyFromPrf.mockResolvedValue(prfKey); + + // Mock implementations + navigatorCredentials.get.mockResolvedValue(publicKeyCredential); + + // Act + const result = await webAuthnLoginService.assertCredential(credentialAssertionOptions); + + // Assert + + expect(webAuthnLoginPrfCryptoService.getLoginWithPrfSalt).toHaveBeenCalled(); + + expect(navigatorCredentials.get).toHaveBeenCalledWith( + expect.objectContaining({ + publicKey: expect.objectContaining({ + ...credentialAssertionOptions.options, + extensions: expect.objectContaining({ + prf: expect.objectContaining({ + eval: expect.objectContaining({ + first: saltArrayBuffer, + }), + }), + }), + }), + }) + ); + + expect(webAuthnLoginPrfCryptoService.createSymmetricKeyFromPrf).toHaveBeenCalledWith( + prfResult + ); + + expect(result).toBeInstanceOf(WebAuthnLoginCredentialAssertionView); + expect(result.token).toEqual(credentialAssertionOptions.token); + + expect(result.deviceResponse).toBeInstanceOf(WebAuthnLoginAssertionResponseRequest); + expect(result.deviceResponse.id).toEqual(publicKeyCredential.id); + expect(result.deviceResponse.rawId).toEqual(publicKeyCredential.rawIdB64Str); + expect(result.deviceResponse.type).toEqual(publicKeyCredential.type); + // extensions being empty could change in the future but for now it is expected + expect(result.deviceResponse.extensions).toEqual({}); + // but it should never contain any PRF information + expect("prf" in result.deviceResponse.extensions).toBe(false); + + expect(result.deviceResponse.response).toEqual({ + authenticatorData: publicKeyCredential.response.authenticatorDataB64Str, + clientDataJSON: publicKeyCredential.response.clientDataJSONB64Str, + signature: publicKeyCredential.response.signatureB64Str, + userHandle: publicKeyCredential.response.userHandleB64Str, + }); + + expect(result.prfKey).toEqual(prfKey); + }); + + it("should return undefined on non-PublicKeyCredential browser response", async () => { + // Arrange + const webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: true }); + const credentialAssertionOptions = buildCredentialAssertionOptions(); + + // Mock the navigatorCredentials.get to return null + navigatorCredentials.get.mockResolvedValue(null); + + // Act + const result = await webAuthnLoginService.assertCredential(credentialAssertionOptions); + + // Assert + expect(result).toBeUndefined(); + }); + + it("should log an error and return undefined when navigatorCredentials.get throws an error", async () => { + // Arrange + const webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: true }); + const credentialAssertionOptions = buildCredentialAssertionOptions(); + + // Mock navigatorCredentials.get to throw an error + const errorMessage = "Simulated error"; + navigatorCredentials.get.mockRejectedValue(new Error(errorMessage)); + + // Spy on logService.error + const logServiceErrorSpy = jest.spyOn(logService, "error"); + + // Act + const result = await webAuthnLoginService.assertCredential(credentialAssertionOptions); + + // Assert + expect(result).toBeUndefined(); + expect(logServiceErrorSpy).toHaveBeenCalledWith(expect.any(Error)); + }); + }); + + describe("logIn(...)", () => { + function buildWebAuthnLoginCredentialAssertionView(): WebAuthnLoginCredentialAssertionView { + const publicKeyCredential = new MockPublicKeyCredential(); + + const deviceResponse = new WebAuthnLoginAssertionResponseRequest(publicKeyCredential); + + const prfKey = new SymmetricCryptoKey(randomBytes(32)) as PrfKey; + + return new WebAuthnLoginCredentialAssertionView("mockToken", deviceResponse, prfKey); + } + + it("should accept an assertion with a signed challenge and use it to try and login", async () => { + // Arrange + const webAuthnLoginService = createWebAuthnLoginService({ featureEnabled: true }); + const assertion = buildWebAuthnLoginCredentialAssertionView(); + const mockAuthResult: AuthResult = new AuthResult(); + + jest.spyOn(authService, "logIn").mockResolvedValue(mockAuthResult); + + // Act + const result = await webAuthnLoginService.logIn(assertion); + + // Assert + expect(result).toEqual(mockAuthResult); + + const callArguments = authService.logIn.mock.calls[0]; + expect(callArguments[0]).toBeInstanceOf(WebAuthnLoginCredentials); + }); + }); +}); + +// Test helpers +function randomBytes(length: number): Uint8Array { + return new Uint8Array(Array.from({ length }, (_, k) => k % 255)); +} + +// AuthenticatorAssertionResponse && PublicKeyCredential are only available in secure contexts +// so we need to mock them and assign them to the global object to make them available +// for the tests +class MockAuthenticatorAssertionResponse implements AuthenticatorAssertionResponse { + clientDataJSON: ArrayBuffer = randomBytes(32).buffer; + authenticatorData: ArrayBuffer = randomBytes(196).buffer; + signature: ArrayBuffer = randomBytes(72).buffer; + userHandle: ArrayBuffer = randomBytes(16).buffer; + + clientDataJSONB64Str = Utils.fromBufferToUrlB64(this.clientDataJSON); + authenticatorDataB64Str = Utils.fromBufferToUrlB64(this.authenticatorData); + signatureB64Str = Utils.fromBufferToUrlB64(this.signature); + userHandleB64Str = Utils.fromBufferToUrlB64(this.userHandle); +} + +class MockPublicKeyCredential implements PublicKeyCredential { + authenticatorAttachment = "cross-platform"; + id = "mockCredentialId"; + type = "public-key"; + rawId: ArrayBuffer = randomBytes(32).buffer; + rawIdB64Str = Utils.fromBufferToUrlB64(this.rawId); + + response: MockAuthenticatorAssertionResponse = new MockAuthenticatorAssertionResponse(); + + // Use random 64 character hex string (32 bytes - matters for symmetric key creation) + // to represent the prf key binary data and convert to ArrayBuffer + // Creating the array buffer from a known hex value allows us to + // assert on the value in tests + private prfKeyArrayBuffer: ArrayBuffer = Utils.hexStringToArrayBuffer( + "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + ); + + getClientExtensionResults(): any { + return { + prf: { + results: { + first: this.prfKeyArrayBuffer, + }, + }, + }; + } + + static isConditionalMediationAvailable(): Promise { + return Promise.resolve(false); + } + + static isUserVerifyingPlatformAuthenticatorAvailable(): Promise { + return Promise.resolve(false); + } +} + +function buildCredentialAssertionOptions(): WebAuthnLoginCredentialAssertionOptionsView { + // Mock credential assertion options + const challenge = "6CG3jqMCVASJVXySMi9KWw"; + const token = "BWWebAuthnLoginAssertionOptions_CfDJ_2KBN892w"; + const timeout = 60000; + const rpId = "localhost"; + const allowCredentials = [] as PublicKeyCredentialDescriptor[]; + const userVerification = "required"; + const objectName = "webAuthnLoginAssertionOptions"; + + const credentialAssertionOptionsServerResponse = { + options: { + challenge: challenge, + timeout: timeout, + rpId: rpId, + allowCredentials: allowCredentials, + userVerification: userVerification, + status: "ok", + errorMessage: "", + }, + token: token, + object: objectName, + }; + + const credentialAssertionOptionsResponse = new CredentialAssertionOptionsResponse( + credentialAssertionOptionsServerResponse + ); + + return new WebAuthnLoginCredentialAssertionOptionsView( + credentialAssertionOptionsResponse.options, + credentialAssertionOptionsResponse.token + ); +} diff --git a/libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts b/libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts new file mode 100644 index 0000000000..43fcf00c3d --- /dev/null +++ b/libs/common/src/auth/services/webauthn-login/webauthn-login.service.ts @@ -0,0 +1,93 @@ +import { Observable } from "rxjs"; + +import { FeatureFlag } from "../../../enums/feature-flag.enum"; +import { ConfigServiceAbstraction } from "../../../platform/abstractions/config/config.service.abstraction"; +import { LogService } from "../../../platform/abstractions/log.service"; +import { PrfKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { AuthService } from "../../abstractions/auth.service"; +import { WebAuthnLoginApiServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-api.service.abstraction"; +import { WebAuthnLoginPrfCryptoServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-prf-crypto.service.abstraction"; +import { WebAuthnLoginServiceAbstraction } from "../../abstractions/webauthn/webauthn-login.service.abstraction"; +import { AuthResult } from "../../models/domain/auth-result"; +import { WebAuthnLoginCredentials } from "../../models/domain/login-credentials"; +import { WebAuthnLoginCredentialAssertionOptionsView } from "../../models/view/webauthn-login/webauthn-login-credential-assertion-options.view"; +import { WebAuthnLoginCredentialAssertionView } from "../../models/view/webauthn-login/webauthn-login-credential-assertion.view"; + +import { WebAuthnLoginAssertionResponseRequest } from "./request/webauthn-login-assertion-response.request"; + +export class WebAuthnLoginService implements WebAuthnLoginServiceAbstraction { + readonly enabled$: Observable; + + private navigatorCredentials: CredentialsContainer; + + constructor( + private webAuthnLoginApiService: WebAuthnLoginApiServiceAbstraction, + private authService: AuthService, + private configService: ConfigServiceAbstraction, + private webAuthnLoginPrfCryptoService: WebAuthnLoginPrfCryptoServiceAbstraction, + private window: Window, + private logService?: LogService + ) { + this.enabled$ = this.configService.getFeatureFlag$(FeatureFlag.PasswordlessLogin, false); + this.navigatorCredentials = this.window.navigator.credentials; + } + + async getCredentialAssertionOptions(): Promise { + const response = await this.webAuthnLoginApiService.getCredentialAssertionOptions(); + return new WebAuthnLoginCredentialAssertionOptionsView(response.options, response.token); + } + + async assertCredential( + credentialAssertionOptions: WebAuthnLoginCredentialAssertionOptionsView + ): Promise { + const nativeOptions: CredentialRequestOptions = { + publicKey: credentialAssertionOptions.options, + }; + // TODO: Remove `any` when typescript typings add support for PRF + nativeOptions.publicKey.extensions = { + prf: { eval: { first: await this.webAuthnLoginPrfCryptoService.getLoginWithPrfSalt() } }, + } as any; + + try { + const response = await this.navigatorCredentials.get(nativeOptions); + if (!(response instanceof PublicKeyCredential)) { + return undefined; + } + // TODO: Remove `any` when typescript typings add support for PRF + const prfResult = (response.getClientExtensionResults() as any).prf?.results?.first; + let symmetricPrfKey: PrfKey | undefined; + if (prfResult != undefined) { + symmetricPrfKey = await this.webAuthnLoginPrfCryptoService.createSymmetricKeyFromPrf( + prfResult + ); + } + + const deviceResponse = new WebAuthnLoginAssertionResponseRequest(response); + + // Verify that we aren't going to send PRF information to the server in any case. + // Note: this will only happen if a dev has done something wrong. + if ("prf" in deviceResponse.extensions) { + throw new Error("PRF information is not allowed to be sent to the server."); + } + + return new WebAuthnLoginCredentialAssertionView( + credentialAssertionOptions.token, + deviceResponse, + symmetricPrfKey + ); + } catch (error) { + this.logService?.error(error); + return undefined; + } + } + + async logIn(assertion: WebAuthnLoginCredentialAssertionView): Promise { + const credential = new WebAuthnLoginCredentials( + assertion.token, + assertion.deviceResponse, + assertion.prfKey + ); + const result = await this.authService.logIn(credential); + return result; + } +} diff --git a/libs/common/src/platform/misc/utils.spec.ts b/libs/common/src/platform/misc/utils.spec.ts index 0fd76a8092..0a76493a01 100644 --- a/libs/common/src/platform/misc/utils.spec.ts +++ b/libs/common/src/platform/misc/utils.spec.ts @@ -244,6 +244,243 @@ describe("Utils Service", () => { }); }); + function runInBothEnvironments(testName: string, testFunc: () => void): void { + const environments = [ + { isNode: true, name: "Node environment" }, + { isNode: false, name: "non-Node environment" }, + ]; + + environments.forEach((env) => { + it(`${testName} in ${env.name}`, () => { + Utils.isNode = env.isNode; + testFunc(); + }); + }); + } + + const asciiHelloWorldArray = [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100]; + const b64HelloWorldString = "aGVsbG8gd29ybGQ="; + + describe("fromBufferToB64(...)", () => { + const originalIsNode = Utils.isNode; + + afterEach(() => { + Utils.isNode = originalIsNode; + }); + + runInBothEnvironments("should convert an ArrayBuffer to a b64 string", () => { + const buffer = new Uint8Array(asciiHelloWorldArray).buffer; + const b64String = Utils.fromBufferToB64(buffer); + expect(b64String).toBe(b64HelloWorldString); + }); + + runInBothEnvironments("should return an empty string for an empty ArrayBuffer", () => { + const buffer = new Uint8Array([]).buffer; + const b64String = Utils.fromBufferToB64(buffer); + expect(b64String).toBe(""); + }); + + runInBothEnvironments("should return null for null input", () => { + const b64String = Utils.fromBufferToB64(null); + expect(b64String).toBeNull(); + }); + }); + + describe("fromB64ToArray(...)", () => { + runInBothEnvironments("should convert a b64 string to an Uint8Array", () => { + const expectedArray = new Uint8Array(asciiHelloWorldArray); + + const resultArray = Utils.fromB64ToArray(b64HelloWorldString); + + expect(resultArray).toEqual(expectedArray); + }); + + runInBothEnvironments("should return null for null input", () => { + const expectedArray = Utils.fromB64ToArray(null); + expect(expectedArray).toBeNull(); + }); + + // Hmmm... this passes in browser but not in node + // as node doesn't throw an error for invalid base64 strings. + // It instead produces a buffer with the bytes that could be decoded + // and ignores the rest after an invalid character. + // https://github.com/nodejs/node/issues/8569 + // This could be mitigated with a regex check before decoding... + // runInBothEnvironments("should throw an error for invalid base64 string", () => { + // const invalidB64String = "invalid base64"; + // expect(() => { + // Utils.fromB64ToArrayBuffer(invalidB64String); + // }).toThrow(); + // }); + }); + + describe("Base64 and ArrayBuffer round trip conversions", () => { + const originalIsNode = Utils.isNode; + + afterEach(() => { + Utils.isNode = originalIsNode; + }); + + runInBothEnvironments( + "should correctly round trip convert from ArrayBuffer to base64 and back", + () => { + // Start with a known ArrayBuffer + const originalArray = new Uint8Array(asciiHelloWorldArray); + const originalBuffer = originalArray.buffer; + + // Convert ArrayBuffer to a base64 string + const b64String = Utils.fromBufferToB64(originalBuffer); + + // Convert that base64 string back to an ArrayBuffer + const roundTrippedBuffer = Utils.fromB64ToArray(b64String).buffer; + const roundTrippedArray = new Uint8Array(roundTrippedBuffer); + + // Compare the original ArrayBuffer with the round-tripped ArrayBuffer + expect(roundTrippedArray).toEqual(originalArray); + } + ); + + runInBothEnvironments( + "should correctly round trip convert from base64 to ArrayBuffer and back", + () => { + // Convert known base64 string to ArrayBuffer + const bufferFromB64 = Utils.fromB64ToArray(b64HelloWorldString).buffer; + + // Convert the ArrayBuffer back to a base64 string + const roundTrippedB64String = Utils.fromBufferToB64(bufferFromB64); + + // Compare the original base64 string with the round-tripped base64 string + expect(roundTrippedB64String).toBe(b64HelloWorldString); + } + ); + }); + + describe("fromBufferToHex(...)", () => { + const originalIsNode = Utils.isNode; + + afterEach(() => { + Utils.isNode = originalIsNode; + }); + + /** + * Creates a string that represents a sequence of hexadecimal byte values in ascending order. + * Each byte value corresponds to its position in the sequence. + * + * @param {number} length - The number of bytes to include in the string. + * @return {string} A string of hexadecimal byte values in sequential order. + * + * @example + * // Returns '000102030405060708090a0b0c0d0e0f101112...ff' + * createSequentialHexByteString(256); + */ + function createSequentialHexByteString(length: number) { + let sequentialHexString = ""; + for (let i = 0; i < length; i++) { + // Convert the number to a hex string and pad with leading zeros if necessary + const hexByte = i.toString(16).padStart(2, "0"); + sequentialHexString += hexByte; + } + return sequentialHexString; + } + + runInBothEnvironments("should convert an ArrayBuffer to a hex string", () => { + const buffer = new Uint8Array([0, 1, 10, 16, 255]).buffer; + const hexString = Utils.fromBufferToHex(buffer); + expect(hexString).toBe("00010a10ff"); + }); + + runInBothEnvironments("should handle an empty buffer", () => { + const buffer = new ArrayBuffer(0); + const hexString = Utils.fromBufferToHex(buffer); + expect(hexString).toBe(""); + }); + + runInBothEnvironments( + "should correctly convert a large buffer containing a repeating sequence of all 256 unique byte values to hex", + () => { + const largeBuffer = new Uint8Array(1024).map((_, index) => index % 256).buffer; + const hexString = Utils.fromBufferToHex(largeBuffer); + const expectedHexString = createSequentialHexByteString(256).repeat(4); + expect(hexString).toBe(expectedHexString); + } + ); + + runInBothEnvironments("should correctly convert a buffer with a single byte to hex", () => { + const singleByteBuffer = new Uint8Array([0xab]).buffer; + const hexString = Utils.fromBufferToHex(singleByteBuffer); + expect(hexString).toBe("ab"); + }); + + runInBothEnvironments( + "should correctly convert a buffer with an odd number of bytes to hex", + () => { + const oddByteBuffer = new Uint8Array([0x01, 0x23, 0x45, 0x67, 0x89]).buffer; + const hexString = Utils.fromBufferToHex(oddByteBuffer); + expect(hexString).toBe("0123456789"); + } + ); + }); + + describe("hexStringToArrayBuffer(...)", () => { + test("should convert a hex string to an ArrayBuffer correctly", () => { + const hexString = "ff0a1b"; // Arbitrary hex string + const expectedResult = new Uint8Array([255, 10, 27]).buffer; + const result = Utils.hexStringToArrayBuffer(hexString); + expect(new Uint8Array(result)).toEqual(new Uint8Array(expectedResult)); + }); + + test("should throw an error if the hex string length is not even", () => { + const hexString = "abc"; // Odd number of characters + expect(() => { + Utils.hexStringToArrayBuffer(hexString); + }).toThrow("HexString has to be an even length"); + }); + + test("should convert a hex string representing zero to an ArrayBuffer correctly", () => { + const hexString = "00"; + const expectedResult = new Uint8Array([0]).buffer; + const result = Utils.hexStringToArrayBuffer(hexString); + expect(new Uint8Array(result)).toEqual(new Uint8Array(expectedResult)); + }); + + test("should handle an empty hex string", () => { + const hexString = ""; + const expectedResult = new ArrayBuffer(0); + const result = Utils.hexStringToArrayBuffer(hexString); + expect(result).toEqual(expectedResult); + }); + + test("should convert a long hex string to an ArrayBuffer correctly", () => { + const hexString = "0102030405060708090a0b0c0d0e0f"; + const expectedResult = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]) + .buffer; + const result = Utils.hexStringToArrayBuffer(hexString); + expect(new Uint8Array(result)).toEqual(new Uint8Array(expectedResult)); + }); + }); + + describe("ArrayBuffer and Hex string round trip conversions", () => { + runInBothEnvironments( + "should allow round-trip conversion from ArrayBuffer to hex and back", + () => { + const originalBuffer = new Uint8Array([10, 20, 30, 40, 255]).buffer; // arbitrary buffer + const hexString = Utils.fromBufferToHex(originalBuffer); + const roundTripBuffer = Utils.hexStringToArrayBuffer(hexString); + expect(new Uint8Array(roundTripBuffer)).toEqual(new Uint8Array(originalBuffer)); + } + ); + + runInBothEnvironments( + "should allow round-trip conversion from hex to ArrayBuffer and back", + () => { + const hexString = "0a141e28ff"; // arbitrary hex string + const bufferFromHex = Utils.hexStringToArrayBuffer(hexString); + const roundTripHexString = Utils.fromBufferToHex(bufferFromHex); + expect(roundTripHexString).toBe(hexString); + } + ); + }); + describe("mapToRecord", () => { it("should handle null", () => { expect(Utils.mapToRecord(null)).toEqual(null); diff --git a/libs/common/src/platform/misc/utils.ts b/libs/common/src/platform/misc/utils.ts index 2feb6288af..776ec51a26 100644 --- a/libs/common/src/platform/misc/utils.ts +++ b/libs/common/src/platform/misc/utils.ts @@ -170,6 +170,43 @@ export class Utils { } } + /** + * Converts a hex string to an ArrayBuffer. + * Note: this doesn't need any Node specific code as parseInt() / ArrayBuffer / Uint8Array + * work the same in Node and the browser. + * @param {string} hexString - A string of hexadecimal characters. + * @returns {ArrayBuffer} The ArrayBuffer representation of the hex string. + */ + static hexStringToArrayBuffer(hexString: string): ArrayBuffer { + // Check if the hexString has an even length, as each hex digit represents half a byte (4 bits), + // and it takes two hex digits to represent a full byte (8 bits). + if (hexString.length % 2 !== 0) { + throw "HexString has to be an even length"; + } + + // Create an ArrayBuffer with a length that is half the length of the hex string, + // because each pair of hex digits will become a single byte. + const arrayBuffer = new ArrayBuffer(hexString.length / 2); + + // Create a Uint8Array view on top of the ArrayBuffer (each position represents a byte) + // as ArrayBuffers cannot be edited directly. + const uint8Array = new Uint8Array(arrayBuffer); + + // Loop through the bytes + for (let i = 0; i < uint8Array.length; i++) { + // Extract two hex characters (1 byte) + const hexByte = hexString.substr(i * 2, 2); + + // Convert hexByte into a decimal value from base 16. (ex: ff --> 255) + const byteValue = parseInt(hexByte, 16); + + // Place the byte value into the uint8Array + uint8Array[i] = byteValue; + } + + return arrayBuffer; + } + static fromUrlB64ToB64(urlB64Str: string): string { let output = urlB64Str.replace(/-/g, "+").replace(/_/g, "/"); switch (output.length % 4) { diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 2275c2015f..423cb38be1 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -42,6 +42,7 @@ import { PasswordTokenRequest } from "../auth/models/request/identity-token/pass import { SsoTokenRequest } from "../auth/models/request/identity-token/sso-token.request"; import { TokenTwoFactorRequest } from "../auth/models/request/identity-token/token-two-factor.request"; import { UserApiTokenRequest } from "../auth/models/request/identity-token/user-api-token.request"; +import { WebAuthnLoginTokenRequest } from "../auth/models/request/identity-token/webauthn-login-token.request"; import { KeyConnectorUserKeyRequest } from "../auth/models/request/key-connector-user-key.request"; import { PasswordHintRequest } from "../auth/models/request/password-hint.request"; import { PasswordRequest } from "../auth/models/request/password.request"; @@ -180,7 +181,11 @@ export class ApiService implements ApiServiceAbstraction { // Auth APIs async postIdentityToken( - request: UserApiTokenRequest | PasswordTokenRequest | SsoTokenRequest + request: + | UserApiTokenRequest + | PasswordTokenRequest + | SsoTokenRequest + | WebAuthnLoginTokenRequest ): Promise { const headers = new Headers({ "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",