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:
parent
1b7a9858a4
commit
25ebbec0eb
@ -61,6 +61,7 @@ export interface AssertCredentialParams {
|
||||
challenge: string;
|
||||
userVerification?: UserVerification;
|
||||
timeout: number;
|
||||
sameOriginWithAncestors: boolean;
|
||||
}
|
||||
|
||||
export interface AssertCredentialResult {
|
||||
|
@ -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> = {}): 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 */
|
||||
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user