1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-03-02 03:41:09 +01:00

[EC-598] feat: implement assertCredential

This commit is contained in:
Andreas Coroiu 2023-03-31 09:41:28 +02:00
parent 1b7a9858a4
commit 25ebbec0eb
No known key found for this signature in database
GPG Key ID: E70B5FFC81DFEC1A
3 changed files with 256 additions and 10 deletions

View File

@ -61,6 +61,7 @@ export interface AssertCredentialParams {
challenge: string;
userVerification?: UserVerification;
timeout: number;
sameOriginWithAncestors: boolean;
}
export interface AssertCredentialResult {

View File

@ -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 callerOrigins 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> = {}): 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 */

View File

@ -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<CreateCredentialResult> {
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<AssertCredentialResult> {
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),
};
}
}