diff --git a/libs/common/src/webauthn/abstractions/fido2-client.service.abstraction.ts b/libs/common/src/webauthn/abstractions/fido2-client.service.abstraction.ts index c0cd0bcc39b..6402cd4b774 100644 --- a/libs/common/src/webauthn/abstractions/fido2-client.service.abstraction.ts +++ b/libs/common/src/webauthn/abstractions/fido2-client.service.abstraction.ts @@ -61,6 +61,7 @@ export interface AssertCredentialParams { challenge: string; userVerification?: UserVerification; timeout: number; + sameOriginWithAncestors: boolean; } export interface AssertCredentialResult { diff --git a/libs/common/src/webauthn/services/fido2-client.service.spec.ts b/libs/common/src/webauthn/services/fido2-client.service.spec.ts index 96ff96268de..6996e317326 100644 --- a/libs/common/src/webauthn/services/fido2-client.service.spec.ts +++ b/libs/common/src/webauthn/services/fido2-client.service.spec.ts @@ -4,9 +4,14 @@ import { Utils } from "../../misc/utils"; import { Fido2AutenticatorError, Fido2AutenticatorErrorCode, + Fido2AuthenticatorGetAssertionResult, Fido2AuthenticatorMakeCredentialResult, } from "../abstractions/fido2-authenticator.service.abstraction"; -import { CreateCredentialParams } from "../abstractions/fido2-client.service.abstraction"; +import { + AssertCredentialParams, + CreateCredentialParams, +} from "../abstractions/fido2-client.service.abstraction"; +import { Fido2Utils } from "../abstractions/fido2-utils"; import { Fido2AuthenticatorService } from "./fido2-authenticator.service"; import { Fido2ClientService } from "./fido2-client.service"; @@ -208,6 +213,157 @@ describe("FidoAuthenticatorService", () => { }; } }); + + describe("assertCredential", () => { + describe("invalid params", () => { + // Spec: If callerOrigin is an opaque origin, return a DOMException whose name is "NotAllowedError", and terminate this algorithm. + // Not sure how to check this, or if it matters. + it.todo("should throw error if origin is an opaque origin"); + + // Spec: Let effectiveDomain be the callerOrigin’s effective domain. If effective domain is not a valid domain, then return a DOMException whose name is "SecurityError" and terminate this algorithm. + it("should throw error if origin is not a valid domain name", async () => { + const params = createParams({ + origin: "invalid-domain-name", + }); + + const result = async () => await client.assertCredential(params); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "SecurityError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + // Spec: If options.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, return a DOMException whose name is "SecurityError", and terminate this algorithm. + it("should throw error if rp.id does not match origin effective domain", async () => { + const params = createParams({ + origin: "passwordless.dev", + rpId: "bitwarden.com", + }); + + const result = async () => await client.assertCredential(params); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "SecurityError" }); + await rejects.toBeInstanceOf(DOMException); + }); + }); + + describe("aborting", () => { + // Spec: If the options.signal is present and its aborted flag is set to true, return a DOMException whose name is "AbortError" and terminate this algorithm. + it("should throw error if aborting using abort controller", async () => { + const params = createParams({}); + const abortController = new AbortController(); + abortController.abort(); + + const result = async () => await client.assertCredential(params, abortController); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "AbortError" }); + await rejects.toBeInstanceOf(DOMException); + }); + }); + + describe("assert credential", () => { + // Spec: If any authenticator returns an error status equivalent to "InvalidStateError", Return a DOMException whose name is "InvalidStateError" and terminate this algorithm. + it("should throw error if authenticator throws InvalidState", async () => { + const params = createParams(); + authenticator.getAssertion.mockRejectedValue( + new Fido2AutenticatorError(Fido2AutenticatorErrorCode.InvalidState) + ); + + const result = async () => await client.assertCredential(params); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "InvalidStateError" }); + await rejects.toBeInstanceOf(DOMException); + }); + + // This keeps sensetive information form leaking + it("should throw NotAllowedError if authenticator throws unknown error", async () => { + const params = createParams(); + authenticator.getAssertion.mockRejectedValue(new Error("unknown error")); + + const result = async () => await client.assertCredential(params); + + const rejects = expect(result).rejects; + await rejects.toMatchObject({ name: "NotAllowedError" }); + await rejects.toBeInstanceOf(DOMException); + }); + }); + + describe("assert non-discoverable credential", () => { + it("should call authenticator.makeCredential", async () => { + const allowedCredentialIds = [Utils.newGuid(), Utils.newGuid(), "not-a-guid"]; + const params = createParams({ + userVerification: "required", + allowedCredentialIds, + }); + authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult()); + + await client.assertCredential(params); + + expect(authenticator.getAssertion).toHaveBeenCalledWith( + expect.objectContaining({ + requireUserVerification: true, + rpId: RpId, + allowCredentialDescriptorList: [ + expect.objectContaining({ + id: Utils.guidToRawFormat(allowedCredentialIds[0]), + }), + expect.objectContaining({ + id: Utils.guidToRawFormat(allowedCredentialIds[1]), + }), + ], + }), + expect.anything() + ); + }); + }); + + describe("assert discoverable credential", () => { + it("should call authenticator.makeCredential", async () => { + const params = createParams({ + userVerification: "required", + allowedCredentialIds: [], + }); + authenticator.getAssertion.mockResolvedValue(createAuthenticatorAssertResult()); + + await client.assertCredential(params); + + expect(authenticator.getAssertion).toHaveBeenCalledWith( + expect.objectContaining({ + requireUserVerification: true, + rpId: RpId, + allowCredentialDescriptorList: [], + }), + expect.anything() + ); + }); + }); + + function createParams(params: Partial = {}): AssertCredentialParams { + return { + allowedCredentialIds: params.allowedCredentialIds ?? [], + challenge: params.challenge ?? Fido2Utils.bufferToString(randomBytes(16)), + origin: params.origin ?? RpId, + rpId: params.rpId ?? RpId, + timeout: params.timeout, + userVerification: params.userVerification, + sameOriginWithAncestors: true, + }; + } + + function createAuthenticatorAssertResult(): Fido2AuthenticatorGetAssertionResult { + return { + selectedCredential: { + id: Utils.newGuid(), + userHandle: randomBytes(32), + }, + authenticatorData: randomBytes(64), + signature: randomBytes(64), + }; + } + }); }); /** This is a fake function that always returns the same byte sequence */ diff --git a/libs/common/src/webauthn/services/fido2-client.service.ts b/libs/common/src/webauthn/services/fido2-client.service.ts index 1918764472d..ed50f76cbff 100644 --- a/libs/common/src/webauthn/services/fido2-client.service.ts +++ b/libs/common/src/webauthn/services/fido2-client.service.ts @@ -4,8 +4,10 @@ import { Utils } from "../../misc/utils"; import { Fido2AutenticatorError, Fido2AutenticatorErrorCode, + Fido2AuthenticatorGetAssertionParams, Fido2AuthenticatorMakeCredentialsParams, Fido2AuthenticatorService, + PublicKeyCredentialDescriptor, } from "../abstractions/fido2-authenticator.service.abstraction"; import { AssertCredentialParams, @@ -23,7 +25,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { async createCredential( params: CreateCredentialParams, - abortController: AbortController = new AbortController() + abortController = new AbortController() ): Promise { if (!params.sameOriginWithAncestors) { throw new DOMException("Invalid 'sameOriginWithAncestors' value", "NotAllowedError"); @@ -81,6 +83,21 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { params.timeout ); + const excludeCredentialDescriptorList: PublicKeyCredentialDescriptor[] = []; + + if (params.excludeCredentials !== undefined) { + for (const credential of params.excludeCredentials) { + try { + excludeCredentialDescriptorList.push({ + id: Fido2Utils.stringToBuffer(credential.id), + transports: credential.transports, + type: credential.type, + }); + // eslint-disable-next-line no-empty + } catch {} + } + } + const makeCredentialParams: Fido2AuthenticatorMakeCredentialsParams = { requireResidentKey: params.authenticatorSelection?.residentKey === "required" || @@ -88,11 +105,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { params.authenticatorSelection?.requireResidentKey === true), requireUserVerification: params.authenticatorSelection?.userVerification === "required", enterpriseAttestationPossible: params.attestation === "enterprise", - excludeCredentialDescriptorList: params.excludeCredentials?.map((c) => ({ - id: Fido2Utils.stringToBuffer(c.id), - transports: c.transports, - type: c.type, - })), + excludeCredentialDescriptorList, credTypesAndPubKeyAlgs, hash: clientDataHash, rpEntity: { @@ -136,11 +149,87 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction { }; } - assertCredential( + async assertCredential( params: AssertCredentialParams, - abortController?: AbortController + abortController = new AbortController() ): Promise { - throw new Error("Not implemented"); + const { domain: effectiveDomain } = parse(params.origin, { allowPrivateDomains: true }); + if (effectiveDomain == undefined) { + throw new DOMException("'origin' is not a valid domain", "SecurityError"); + } + + const rpId = params.rpId ?? effectiveDomain; + if (effectiveDomain !== rpId) { + throw new DOMException("'rp.id' does not match origin effective domain", "SecurityError"); + } + + const collectedClientData = { + type: "webauthn.create", + challenge: params.challenge, + origin: params.origin, + crossOrigin: !params.sameOriginWithAncestors, + // tokenBinding: {} // Not currently supported + }; + const clientDataJSON = JSON.stringify(collectedClientData); + const clientDataJSONBytes = Utils.fromByteStringToArray(clientDataJSON); + const clientDataHash = await crypto.subtle.digest({ name: "SHA-256" }, clientDataJSONBytes); + + if (abortController.signal.aborted) { + throw new DOMException(undefined, "AbortError"); + } + + const timeout = setAbortTimeout(abortController, params.userVerification, params.timeout); + + const allowCredentialDescriptorList: PublicKeyCredentialDescriptor[] = []; + for (const id of params.allowedCredentialIds) { + try { + allowCredentialDescriptorList.push({ + id: Utils.guidToRawFormat(id), + type: "public-key", + }); + // eslint-disable-next-line no-empty + } catch {} + } + + const getAssertionParams: Fido2AuthenticatorGetAssertionParams = { + rpId, + requireUserVerification: params.userVerification === "required", + hash: clientDataHash, + allowCredentialDescriptorList, + extensions: {}, + }; + + let getAssertionResult; + try { + getAssertionResult = await this.authenticator.getAssertion( + getAssertionParams, + abortController + ); + } catch (error) { + if ( + error instanceof Fido2AutenticatorError && + error.errorCode === Fido2AutenticatorErrorCode.InvalidState + ) { + throw new DOMException(undefined, "InvalidStateError"); + } + throw new DOMException(undefined, "NotAllowedError"); + } + + if (abortController.signal.aborted) { + throw new DOMException(undefined, "AbortError"); + } + clearTimeout(timeout); + + return { + authenticatorData: Fido2Utils.bufferToString(getAssertionResult.authenticatorData), + clientDataJSON, + credentialId: getAssertionResult.selectedCredential.id, + userHandle: + getAssertionResult.selectedCredential.userHandle !== undefined + ? Fido2Utils.bufferToString(getAssertionResult.selectedCredential.userHandle) + : undefined, + signature: Fido2Utils.bufferToString(getAssertionResult.signature), + }; } }