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:
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user