1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-21 02:33:46 +00:00

[EC-598] feat: fully implement createCredential

This commit is contained in:
Andreas Coroiu
2023-03-30 16:04:49 +02:00
parent 43a13cb451
commit 1b7a9858a4
4 changed files with 179 additions and 8 deletions

View File

@@ -25,7 +25,7 @@ export interface CreateCredentialParams {
excludeCredentials?: { excludeCredentials?: {
id: string; // b64 encoded id: string; // b64 encoded
transports?: ("ble" | "internal" | "nfc" | "usb")[]; transports?: ("ble" | "internal" | "nfc" | "usb")[];
// type: "public-key"; // not used type: "public-key";
}[]; }[];
extensions?: { extensions?: {
appid?: string; appid?: string;

View File

@@ -137,7 +137,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
); );
return { return {
credentialId: Fido2Utils.stringToBuffer(credentialId), credentialId: Utils.guidToRawFormat(credentialId),
attestationObject, attestationObject,
authData, authData,
publicKeyAlgorithm: -7, publicKeyAlgorithm: -7,

View File

@@ -1,5 +1,11 @@
import { mock, MockProxy } from "jest-mock-extended"; import { mock, MockProxy } from "jest-mock-extended";
import { Utils } from "../../misc/utils";
import {
Fido2AutenticatorError,
Fido2AutenticatorErrorCode,
Fido2AuthenticatorMakeCredentialResult,
} from "../abstractions/fido2-authenticator.service.abstraction";
import { CreateCredentialParams } from "../abstractions/fido2-client.service.abstraction"; import { CreateCredentialParams } from "../abstractions/fido2-client.service.abstraction";
import { Fido2AuthenticatorService } from "./fido2-authenticator.service"; import { Fido2AuthenticatorService } from "./fido2-authenticator.service";
@@ -115,6 +121,57 @@ describe("FidoAuthenticatorService", () => {
}); });
}); });
describe("creating a new credential", () => {
it("should call authenticator.makeCredential", async () => {
const params = createParams({
authenticatorSelection: { residentKey: "required", userVerification: "required" },
});
authenticator.makeCredential.mockResolvedValue(createAuthenticatorMakeResult());
await client.createCredential(params);
expect(authenticator.makeCredential).toHaveBeenCalledWith(
expect.objectContaining({
requireResidentKey: true,
requireUserVerification: true,
rpEntity: expect.objectContaining({
id: RpId,
}),
userEntity: expect.objectContaining({
displayName: params.user.displayName,
}),
}),
expect.anything()
);
});
// 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.makeCredential.mockRejectedValue(
new Fido2AutenticatorError(Fido2AutenticatorErrorCode.InvalidState)
);
const result = async () => await client.createCredential(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.makeCredential.mockRejectedValue(new Error("unknown error"));
const result = async () => await client.createCredential(params);
const rejects = expect(result).rejects;
await rejects.toMatchObject({ name: "NotAllowedError" });
await rejects.toBeInstanceOf(DOMException);
});
});
function createParams(params: Partial<CreateCredentialParams> = {}): CreateCredentialParams { function createParams(params: Partial<CreateCredentialParams> = {}): CreateCredentialParams {
return { return {
origin: params.origin ?? "bitwarden.com", origin: params.origin ?? "bitwarden.com",
@@ -141,5 +198,19 @@ describe("FidoAuthenticatorService", () => {
timeout: params.timeout, timeout: params.timeout,
}; };
} }
function createAuthenticatorMakeResult(): Fido2AuthenticatorMakeCredentialResult {
return {
credentialId: Utils.guidToRawFormat(Utils.newGuid()),
attestationObject: randomBytes(128),
authData: randomBytes(64),
publicKeyAlgorithm: -7,
};
}
}); });
}); });
/** 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));
}

View File

@@ -1,7 +1,12 @@
import { parse } from "tldts"; import { parse } from "tldts";
import { Utils } from "../../misc/utils"; import { Utils } from "../../misc/utils";
import { Fido2AuthenticatorService } from "../abstractions/fido2-authenticator.service.abstraction"; import {
Fido2AutenticatorError,
Fido2AutenticatorErrorCode,
Fido2AuthenticatorMakeCredentialsParams,
Fido2AuthenticatorService,
} from "../abstractions/fido2-authenticator.service.abstraction";
import { import {
AssertCredentialParams, AssertCredentialParams,
AssertCredentialResult, AssertCredentialResult,
@@ -9,6 +14,7 @@ import {
CreateCredentialResult, CreateCredentialResult,
Fido2ClientService as Fido2ClientServiceAbstraction, Fido2ClientService as Fido2ClientServiceAbstraction,
PublicKeyCredentialParam, PublicKeyCredentialParam,
UserVerification,
} from "../abstractions/fido2-client.service.abstraction"; } from "../abstractions/fido2-client.service.abstraction";
import { Fido2Utils } from "../abstractions/fido2-utils"; import { Fido2Utils } from "../abstractions/fido2-utils";
@@ -62,15 +68,72 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
// tokenBinding: {} // Not currently supported // tokenBinding: {} // Not currently supported
}; };
const clientDataJSON = JSON.stringify(collectedClientData); const clientDataJSON = JSON.stringify(collectedClientData);
// eslint-disable-next-line @typescript-eslint/no-unused-vars const clientDataJSONBytes = Utils.fromByteStringToArray(clientDataJSON);
const clientDataHash = await crypto.subtle.digest( const clientDataHash = await crypto.subtle.digest({ name: "SHA-256" }, clientDataJSONBytes);
{ name: "SHA-256" },
Utils.fromByteStringToArray(clientDataJSON)
);
if (abortController.signal.aborted) { if (abortController.signal.aborted) {
throw new DOMException(undefined, "AbortError"); throw new DOMException(undefined, "AbortError");
} }
const timeout = setAbortTimeout(
abortController,
params.authenticatorSelection?.userVerification,
params.timeout
);
const makeCredentialParams: Fido2AuthenticatorMakeCredentialsParams = {
requireResidentKey:
params.authenticatorSelection?.residentKey === "required" ||
(params.authenticatorSelection?.residentKey === undefined &&
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,
})),
credTypesAndPubKeyAlgs,
hash: clientDataHash,
rpEntity: {
id: rpId,
name: params.rp.name,
},
userEntity: {
id: Fido2Utils.stringToBuffer(params.user.id),
displayName: params.user.displayName,
},
};
let makeCredentialResult;
try {
makeCredentialResult = await this.authenticator.makeCredential(
makeCredentialParams,
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 {
credentialId: Fido2Utils.bufferToString(makeCredentialResult.credentialId),
attestationObject: Fido2Utils.bufferToString(makeCredentialResult.attestationObject),
authData: Fido2Utils.bufferToString(makeCredentialResult.authData),
publicKeyAlgorithm: makeCredentialResult.publicKeyAlgorithm,
clientDataJSON,
transports: ["web-extension"],
};
} }
assertCredential( assertCredential(
@@ -80,3 +143,40 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
throw new Error("Not implemented"); throw new Error("Not implemented");
} }
} }
const TIMEOUTS = {
NO_VERIFICATION: {
DEFAULT: 120000,
MIN: 30000,
MAX: 180000,
},
WITH_VERIFICATION: {
DEFAULT: 300000,
MIN: 30000,
MAX: 600000,
},
};
function setAbortTimeout(
abortController: AbortController,
userVerification?: UserVerification,
timeout?: number
): number {
let clampedTimeout: number;
if (userVerification === "required") {
timeout = timeout ?? TIMEOUTS.WITH_VERIFICATION.DEFAULT;
clampedTimeout = Math.max(
TIMEOUTS.WITH_VERIFICATION.MIN,
Math.min(timeout, TIMEOUTS.WITH_VERIFICATION.MAX)
);
} else {
timeout = timeout ?? TIMEOUTS.NO_VERIFICATION.DEFAULT;
clampedTimeout = Math.max(
TIMEOUTS.NO_VERIFICATION.MIN,
Math.min(timeout, TIMEOUTS.NO_VERIFICATION.MAX)
);
}
return window.setTimeout(() => abortController.abort(), clampedTimeout);
}