mirror of
https://github.com/bitwarden/browser
synced 2025-12-18 01:03:35 +00:00
[EC-598] feat: implement assertCredential
This commit is contained in:
@@ -61,6 +61,7 @@ export interface AssertCredentialParams {
|
|||||||
challenge: string;
|
challenge: string;
|
||||||
userVerification?: UserVerification;
|
userVerification?: UserVerification;
|
||||||
timeout: number;
|
timeout: number;
|
||||||
|
sameOriginWithAncestors: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssertCredentialResult {
|
export interface AssertCredentialResult {
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ import { Utils } from "../../misc/utils";
|
|||||||
import {
|
import {
|
||||||
Fido2AutenticatorError,
|
Fido2AutenticatorError,
|
||||||
Fido2AutenticatorErrorCode,
|
Fido2AutenticatorErrorCode,
|
||||||
|
Fido2AuthenticatorGetAssertionResult,
|
||||||
Fido2AuthenticatorMakeCredentialResult,
|
Fido2AuthenticatorMakeCredentialResult,
|
||||||
} from "../abstractions/fido2-authenticator.service.abstraction";
|
} 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 { Fido2AuthenticatorService } from "./fido2-authenticator.service";
|
||||||
import { Fido2ClientService } from "./fido2-client.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 */
|
/** This is a fake function that always returns the same byte sequence */
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { Utils } from "../../misc/utils";
|
|||||||
import {
|
import {
|
||||||
Fido2AutenticatorError,
|
Fido2AutenticatorError,
|
||||||
Fido2AutenticatorErrorCode,
|
Fido2AutenticatorErrorCode,
|
||||||
|
Fido2AuthenticatorGetAssertionParams,
|
||||||
Fido2AuthenticatorMakeCredentialsParams,
|
Fido2AuthenticatorMakeCredentialsParams,
|
||||||
Fido2AuthenticatorService,
|
Fido2AuthenticatorService,
|
||||||
|
PublicKeyCredentialDescriptor,
|
||||||
} from "../abstractions/fido2-authenticator.service.abstraction";
|
} from "../abstractions/fido2-authenticator.service.abstraction";
|
||||||
import {
|
import {
|
||||||
AssertCredentialParams,
|
AssertCredentialParams,
|
||||||
@@ -23,7 +25,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
|||||||
|
|
||||||
async createCredential(
|
async createCredential(
|
||||||
params: CreateCredentialParams,
|
params: CreateCredentialParams,
|
||||||
abortController: AbortController = new AbortController()
|
abortController = new AbortController()
|
||||||
): Promise<CreateCredentialResult> {
|
): Promise<CreateCredentialResult> {
|
||||||
if (!params.sameOriginWithAncestors) {
|
if (!params.sameOriginWithAncestors) {
|
||||||
throw new DOMException("Invalid 'sameOriginWithAncestors' value", "NotAllowedError");
|
throw new DOMException("Invalid 'sameOriginWithAncestors' value", "NotAllowedError");
|
||||||
@@ -81,6 +83,21 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
|||||||
params.timeout
|
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 = {
|
const makeCredentialParams: Fido2AuthenticatorMakeCredentialsParams = {
|
||||||
requireResidentKey:
|
requireResidentKey:
|
||||||
params.authenticatorSelection?.residentKey === "required" ||
|
params.authenticatorSelection?.residentKey === "required" ||
|
||||||
@@ -88,11 +105,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
|||||||
params.authenticatorSelection?.requireResidentKey === true),
|
params.authenticatorSelection?.requireResidentKey === true),
|
||||||
requireUserVerification: params.authenticatorSelection?.userVerification === "required",
|
requireUserVerification: params.authenticatorSelection?.userVerification === "required",
|
||||||
enterpriseAttestationPossible: params.attestation === "enterprise",
|
enterpriseAttestationPossible: params.attestation === "enterprise",
|
||||||
excludeCredentialDescriptorList: params.excludeCredentials?.map((c) => ({
|
excludeCredentialDescriptorList,
|
||||||
id: Fido2Utils.stringToBuffer(c.id),
|
|
||||||
transports: c.transports,
|
|
||||||
type: c.type,
|
|
||||||
})),
|
|
||||||
credTypesAndPubKeyAlgs,
|
credTypesAndPubKeyAlgs,
|
||||||
hash: clientDataHash,
|
hash: clientDataHash,
|
||||||
rpEntity: {
|
rpEntity: {
|
||||||
@@ -136,11 +149,87 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
assertCredential(
|
async assertCredential(
|
||||||
params: AssertCredentialParams,
|
params: AssertCredentialParams,
|
||||||
abortController?: AbortController
|
abortController = new AbortController()
|
||||||
): Promise<AssertCredentialResult> {
|
): 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),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user