1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 15:53:27 +00:00

[EC-598] chore: use webauthn authenticator model as base instead of CTAP

This commit is contained in:
Andreas Coroiu
2023-03-24 16:23:00 +01:00
parent 6bf680cacc
commit e327e3f9d8
3 changed files with 114 additions and 116 deletions

View File

@@ -8,16 +8,16 @@ export enum Fido2AlgorithmIdentifier {
}
export enum Fido2AutenticatorErrorCode {
CTAP2_ERR_CREDENTIAL_EXCLUDED,
CTAP2_ERR_UNSUPPORTED_ALGORITHM,
CTAP2_ERR_INVALID_OPTION,
CTAP2_ERR_PIN_AUTH_INVALID,
CTAP2_ERR_OPERATION_DENIED,
Unknown = "UnknownError",
NotSupported = "NotSupportedError",
InvalidState = "InvalidStateError",
NotAllowed = "NotAllowedError",
Constraint = "ConstraintError",
}
export class Fido2AutenticatorError extends Error {
constructor(readonly errorCode: Fido2AutenticatorErrorCode) {
super(Fido2AutenticatorErrorCode[errorCode]);
super(errorCode);
}
}
@@ -25,41 +25,45 @@ export class Fido2AutenticatorError extends Error {
* Parameters for {@link Fido2AuthenticatorService.makeCredential}
*
* @note
* This interface uses the parameter names defined in `fido-v2.0-ps-20190130`
* but the parameter values use the corresponding data structures defined in
* `WD-webauthn-3-20210427`. This is to avoid the unnecessary complexity of
* converting data to CBOR and back.
* This interface represents the input parameters described in
* https://www.w3.org/TR/webauthn-3/#sctn-op-make-cred
*/
export interface Fido2AuthenticatorMakeCredentialsParams {
clientDataHash: BufferSource;
rp: {
/** The hash of the serialized client data, provided by the client. */
hash: BufferSource;
/** The Relying Party's PublicKeyCredentialRpEntity. */
rpEntity: {
name: string;
id?: string;
};
user: {
/** The user accounts PublicKeyCredentialUserEntity, containing the user handle given by the Relying Party. */
userEntity: {
id: BufferSource;
name?: string;
displayName?: string;
icon?: string;
};
pubKeyCredParams: {
/** A sequence of pairs of PublicKeyCredentialType and public key algorithms (COSEAlgorithmIdentifier) requested by the Relying Party. This sequence is ordered from most preferred to least preferred. The authenticator makes a best-effort to create the most preferred credential that it can. */
credTypesAndPubKeyAlgs: {
alg: number;
type: "public-key"; // not used
}[];
excludeList?: {
/** An OPTIONAL list of PublicKeyCredentialDescriptor objects provided by the Relying Party with the intention that, if any of these are known to the authenticator, it SHOULD NOT create a new credential. excludeCredentialDescriptorList contains a list of known credentials. */
excludeCredentialDescriptorList?: {
id: BufferSource;
transports?: ("ble" | "internal" | "nfc" | "usb")[];
type: "public-key"; // not used
}[];
/** A map from extension identifiers to their authenticator extension inputs, created by the client based on the extensions requested by the Relying Party, if any. */
extensions?: {
appid?: string;
appidExclude?: string;
credProps?: boolean;
uvm?: boolean;
};
options?: {
rk?: boolean;
uv?: boolean;
};
pinAuth?: unknown;
/** The effective resident key requirement for credential creation, a Boolean value determined by the client. */
requireResidentKey: boolean;
requireUserVerification: boolean;
/** The constant Boolean value true. It is included here as a pseudo-parameter to simplify applying this abstract authenticator model to implementations that may wish to make a test of user presence optional although WebAuthn does not. */
// requireUserPresence: true; // Always performed
}

View File

@@ -42,41 +42,36 @@ describe("FidoAuthenticatorService", () => {
});
describe("invalid input parameters", () => {
// Spec: If the pubKeyCredParams parameter does not contain a valid COSEAlgorithmIdentifier value that is supported by the authenticator, terminate this procedure and return error code
// Spec: Check if at least one of the specified combinations of PublicKeyCredentialType and cryptographic parameters in credTypesAndPubKeyAlgs is supported. If not, return an error code equivalent to "NotSupportedError" and terminate the operation.
it("should throw error when input does not contain any supported algorithms", async () => {
const result = async () =>
await authenticator.makeCredential(invalidParams.unsupportedAlgorithm);
await expect(result).rejects.toThrowError(
Fido2AutenticatorErrorCode[Fido2AutenticatorErrorCode.CTAP2_ERR_UNSUPPORTED_ALGORITHM]
);
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotSupported);
});
/** Spec: If the option is known but not valid for this command, terminate this procedure */
it("should throw error when rk has invalid value", async () => {
it("should throw error when requireResidentKey has invalid value", async () => {
const result = async () => await authenticator.makeCredential(invalidParams.invalidRk);
await expect(result).rejects.toThrowError(
Fido2AutenticatorErrorCode[Fido2AutenticatorErrorCode.CTAP2_ERR_INVALID_OPTION]
);
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown);
});
/** Spec: If the option is known but not valid for this command, terminate this procedure */
it("should throw error when uv has invalid value", async () => {
it("should throw error when requireUserVerification has invalid value", async () => {
const result = async () => await authenticator.makeCredential(invalidParams.invalidUv);
await expect(result).rejects.toThrowError(
Fido2AutenticatorErrorCode[Fido2AutenticatorErrorCode.CTAP2_ERR_INVALID_OPTION]
);
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown);
});
/** Spec: If pinAuth parameter is present and the pinProtocol is not supported */
it("should throw error when pinAuth parameter is present", async () => {
const result = async () => await authenticator.makeCredential(invalidParams.pinAuthPresent);
/**
* Spec: If requireUserVerification is true and the authenticator cannot perform user verification, return an error code equivalent to "ConstraintError" and terminate the operation.
* Deviation: User verification is checked before checking for excluded credentials
* */
it("should throw error if requireUserVerification is set to true", async () => {
const params = await createCredentialParams({ requireUserVerification: true });
await expect(result).rejects.toThrowError(
Fido2AutenticatorErrorCode[Fido2AutenticatorErrorCode.CTAP2_ERR_PIN_AUTH_INVALID]
);
const result = async () => await authenticator.makeCredential(params);
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Constraint);
});
it("should not request confirmation from user", async () => {
@@ -93,10 +88,6 @@ describe("FidoAuthenticatorService", () => {
});
});
/**
* Spec: Optionally, if the extensions parameter is present, process any extensions that this authenticator supports.
* Currently not supported.
*/
describe.skip("when extensions parameter is present", () => undefined);
describe("when vault contains excluded credential", () => {
@@ -107,7 +98,9 @@ describe("FidoAuthenticatorService", () => {
const excludedCipher = createCipher();
excludedCipherView = await excludedCipher.decrypt();
params = await createCredentialParams({
excludeList: [{ id: Fido2Utils.stringToBuffer(excludedCipher.id), type: "public-key" }],
excludeCredentialDescriptorList: [
{ id: Fido2Utils.stringToBuffer(excludedCipher.id), type: "public-key" },
],
});
cipherService.get.mockImplementation(async (id) =>
id === excludedCipher.id ? excludedCipher : undefined
@@ -115,7 +108,10 @@ describe("FidoAuthenticatorService", () => {
cipherService.getAllDecrypted.mockResolvedValue([excludedCipherView]);
});
/** Spec: wait for user presence */
/**
* Spec: collect an authorization gesture confirming user consent for creating a new credential.
* Deviation: Consent is not asked and the user is simply informed of the situation.
**/
it("should inform user", async () => {
userInterface.informExcludedCredential.mockResolvedValue();
@@ -127,18 +123,15 @@ describe("FidoAuthenticatorService", () => {
expect(userInterface.informExcludedCredential).toHaveBeenCalled();
});
/** Spec: then terminate this procedure and return error code */
/** Spec: return an error code equivalent to "NotAllowedError" and terminate the operation. */
it("should throw error", async () => {
userInterface.informExcludedCredential.mockResolvedValue();
const result = async () => await authenticator.makeCredential(params);
await expect(result).rejects.toThrowError(
Fido2AutenticatorErrorCode[Fido2AutenticatorErrorCode.CTAP2_ERR_CREDENTIAL_EXCLUDED]
);
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed);
});
/** Departure from spec: Check duplication last instead of first */
it("should not inform user of duplication when input data does not pass checks", async () => {
userInterface.informExcludedCredential.mockResolvedValue();
const invalidParams = await createInvalidParams();
@@ -157,18 +150,21 @@ describe("FidoAuthenticatorService", () => {
let params: Fido2AuthenticatorMakeCredentialsParams;
beforeEach(async () => {
params = await createCredentialParams({ options: { rk: true } });
params = await createCredentialParams({ requireResidentKey: true });
});
/** Spec: show the items contained within the user and rp parameter structures to the user. */
/**
* Spec: Collect an authorization gesture confirming user consent for creating a new credential. The prompt for the authorization gesture is shown by the authenticator if it has its own output capability. The prompt SHOULD display rpEntity.id, rpEntity.name, userEntity.name and userEntity.displayName, if possible.
* Deviation: Only `rpEntity.name` and `userEntity.name` is shown.
* */
it("should request confirmation from user", async () => {
userInterface.confirmNewCredential.mockResolvedValue(true);
await authenticator.makeCredential(params);
expect(userInterface.confirmNewCredential).toHaveBeenCalledWith({
credentialName: params.rp.name,
userName: params.user.name,
credentialName: params.rpEntity.name,
userName: params.userEntity.name,
} as NewCredentialParams);
});
@@ -183,30 +179,28 @@ describe("FidoAuthenticatorService", () => {
expect(saved).toEqual(
expect.objectContaining({
type: CipherType.Fido2Key,
name: params.rp.name,
name: params.rpEntity.name,
fido2Key: expect.objectContaining({
keyType: "ECDSA",
keyCurve: "P-256",
rpId: params.rp.id,
rpName: params.rp.name,
userHandle: Fido2Utils.bufferToString(params.user.id),
userName: params.user.name,
rpId: params.rpEntity.id,
rpName: params.rpEntity.name,
userHandle: Fido2Utils.bufferToString(params.userEntity.id),
userName: params.userEntity.name,
}),
})
);
expect(cipherService.createWithServer).toHaveBeenCalledWith(encryptedCipher);
});
/** Spec: If the user declines permission, return the CTAP2_ERR_OPERATION_DENIED error. */
/** Spec: If the user does not consent or if user verification fails, return an error code equivalent to "NotAllowedError" and terminate the operation. */
it("should throw error if user denies creation request", async () => {
userInterface.confirmNewCredential.mockResolvedValue(false);
const result = async () => await authenticator.makeCredential(params);
await expect(result).rejects.toThrowError(
Fido2AutenticatorErrorCode[Fido2AutenticatorErrorCode.CTAP2_ERR_OPERATION_DENIED]
);
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed);
});
});
@@ -226,15 +220,18 @@ describe("FidoAuthenticatorService", () => {
cipherService.getAllDecrypted.mockResolvedValue([existingCipherView]);
});
/** Spec: show the items contained within the user and rp parameter structures to the user. */
/**
* Spec: Collect an authorization gesture confirming user consent for creating a new credential. The prompt for the authorization gesture is shown by the authenticator if it has its own output capability. The prompt SHOULD display rpEntity.id, rpEntity.name, userEntity.name and userEntity.displayName, if possible.
* Deviation: Only `rpEntity.name` and `userEntity.name` is shown.
* */
it("should request confirmation from user", async () => {
userInterface.confirmNewNonDiscoverableCredential.mockResolvedValue(existingCipherView.id);
await authenticator.makeCredential(params);
expect(userInterface.confirmNewNonDiscoverableCredential).toHaveBeenCalledWith({
credentialName: params.rp.name,
userName: params.user.name,
credentialName: params.rpEntity.name,
userName: params.userEntity.name,
} as NewCredentialParams);
});
@@ -254,26 +251,24 @@ describe("FidoAuthenticatorService", () => {
fido2Key: expect.objectContaining({
keyType: "ECDSA",
keyCurve: "P-256",
rpId: params.rp.id,
rpName: params.rp.name,
userHandle: Fido2Utils.bufferToString(params.user.id),
userName: params.user.name,
rpId: params.rpEntity.id,
rpName: params.rpEntity.name,
userHandle: Fido2Utils.bufferToString(params.userEntity.id),
userName: params.userEntity.name,
}),
})
);
expect(cipherService.updateWithServer).toHaveBeenCalledWith(encryptedCipher);
});
/** Spec: If the user declines permission, return the CTAP2_ERR_OPERATION_DENIED error. */
/** Spec: If the user does not consent or if user verification fails, return an error code equivalent to "NotAllowedError" and terminate the operation. */
it("should throw error if user denies creation request", async () => {
userInterface.confirmNewNonDiscoverableCredential.mockResolvedValue(undefined);
const params = await createCredentialParams();
const result = async () => await authenticator.makeCredential(params);
await expect(result).rejects.toThrowError(
Fido2AutenticatorErrorCode[Fido2AutenticatorErrorCode.CTAP2_ERR_OPERATION_DENIED]
);
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed);
});
});
});
@@ -283,41 +278,38 @@ async function createCredentialParams(
params: Partial<Fido2AuthenticatorMakeCredentialsParams> = {}
): Promise<Fido2AuthenticatorMakeCredentialsParams> {
return {
clientDataHash: params.clientDataHash ?? (await createClientDataHash()),
rp: params.rp ?? {
hash: params.hash ?? (await createClientDataHash()),
rpEntity: params.rpEntity ?? {
name: "Bitwarden",
id: RpId,
},
user: params.user ?? {
userEntity: params.userEntity ?? {
id: randomBytes(64),
name: "jane.doe@bitwarden.com",
displayName: "Jane Doe",
icon: " data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAOhJREFUeNpiFI+9E8DAwDAfiAUYSAMfgDiQBVmzlSYnUTqPXf/OANWzngVZ87pKKaIMCGp/BjeEhRjFMKAjx8bQFC2CIs9CpHNxAiYGCsEQM4Cfiwm3AY9f/yZogIcRN4ZahAFv/jAcu4E7xMNtecEYpAakFqsX8me9Yvj07R+G5jR3foaJqWJgOZAaZMAIzAv/kQV05NgZ5hdIMMiKQJIIyEYrDU6wrYkTXjBcefQTvwGwwCoJFGJIBdoMArN3fmToWf+O4SMW14EMeI8rJ8Jcgexn9BwJCoNEaNbEACCN+DSDsjNAgAEAri9Zii/uDMsAAAAASUVORK5CYII=",
},
pubKeyCredParams: params.pubKeyCredParams ?? [
credTypesAndPubKeyAlgs: params.credTypesAndPubKeyAlgs ?? [
{
alg: -7, // ES256
type: "public-key",
},
],
excludeList: params.excludeList ?? [
excludeCredentialDescriptorList: params.excludeCredentialDescriptorList ?? [
{
id: randomBytes(16),
transports: ["internal"],
type: "public-key",
},
],
requireResidentKey: params.requireResidentKey ?? false,
requireUserVerification: params.requireUserVerification ?? false,
extensions: params.extensions ?? {
appid: undefined,
appidExclude: undefined,
credProps: undefined,
uvm: false as boolean,
},
options: params.options ?? {
rk: false as boolean,
uv: false as boolean,
},
pinAuth: params.pinAuth,
};
}
@@ -325,11 +317,10 @@ type InvalidParams = Awaited<ReturnType<typeof createInvalidParams>>;
async function createInvalidParams() {
return {
unsupportedAlgorithm: await createCredentialParams({
pubKeyCredParams: [{ alg: 9001, type: "public-key" }],
credTypesAndPubKeyAlgs: [{ alg: 9001, type: "public-key" }],
}),
invalidRk: await createCredentialParams({ options: { rk: "invalid-value" as any } }),
invalidUv: await createCredentialParams({ options: { uv: "invalid-value" as any } }),
pinAuthPresent: await createCredentialParams({ pinAuth: { key: "value" } }),
invalidRk: await createCredentialParams({ requireResidentKey: "invalid-value" as any }),
invalidUv: await createCredentialParams({ requireUserVerification: "invalid-value" as any }),
};
}

View File

@@ -15,8 +15,8 @@ import { Fido2KeyView } from "../models/view/fido2-key.view";
const KeyUsages: KeyUsage[] = ["sign"];
/**
* Bitwarden implementation of the Authenticator API described by the FIDO Alliance
* https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html
* Bitwarden implementation of the WebAuthn Authenticator Model described by W3C
* https://www.w3.org/TR/webauthn-3/#sctn-authenticator-model
*/
export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstraction {
constructor(
@@ -25,64 +25,67 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
) {}
async makeCredential(params: Fido2AuthenticatorMakeCredentialsParams): Promise<void> {
if (params.pubKeyCredParams.every((p) => p.alg !== Fido2AlgorithmIdentifier.ES256)) {
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.CTAP2_ERR_UNSUPPORTED_ALGORITHM);
if (params.credTypesAndPubKeyAlgs.every((p) => p.alg !== Fido2AlgorithmIdentifier.ES256)) {
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotSupported);
}
if (params.options?.rk != undefined && typeof params.options.rk !== "boolean") {
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.CTAP2_ERR_INVALID_OPTION);
if (params.requireResidentKey != undefined && typeof params.requireResidentKey !== "boolean") {
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown);
}
if (params.options?.uv != undefined && typeof params.options.uv !== "boolean") {
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.CTAP2_ERR_INVALID_OPTION);
if (
params.requireUserVerification != undefined &&
typeof params.requireUserVerification !== "boolean"
) {
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown);
}
if (params.pinAuth != undefined) {
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.CTAP2_ERR_PIN_AUTH_INVALID);
if (params.requireUserVerification) {
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Constraint);
}
const isExcluded = await this.vaultContainsId(
params.excludeList.map((key) => Fido2Utils.bufferToString(key.id))
params.excludeCredentialDescriptorList.map((key) => Fido2Utils.bufferToString(key.id))
);
if (isExcluded) {
await this.userInterface.informExcludedCredential(
[Fido2Utils.bufferToString(params.excludeList[0].id)],
[Fido2Utils.bufferToString(params.excludeCredentialDescriptorList[0].id)],
{
credentialName: params.rp.name,
userName: params.user.name,
credentialName: params.rpEntity.name,
userName: params.userEntity.name,
}
);
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.CTAP2_ERR_CREDENTIAL_EXCLUDED);
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed);
}
if (params.options?.rk) {
if (params.requireResidentKey) {
const userVerification = await this.userInterface.confirmNewCredential({
credentialName: params.rp.name,
userName: params.user.name,
credentialName: params.rpEntity.name,
userName: params.userEntity.name,
});
if (!userVerification) {
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.CTAP2_ERR_OPERATION_DENIED);
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed);
}
const keyPair = await this.createKeyPair();
const cipher = new CipherView();
cipher.type = CipherType.Fido2Key;
cipher.name = params.rp.name;
cipher.name = params.rpEntity.name;
cipher.fido2Key = await this.createKeyView(params, keyPair.privateKey);
const encrypted = await this.cipherService.encrypt(cipher);
await this.cipherService.createWithServer(encrypted);
} else {
const cipherId = await this.userInterface.confirmNewNonDiscoverableCredential({
credentialName: params.rp.name,
userName: params.user.name,
credentialName: params.rpEntity.name,
userName: params.userEntity.name,
});
if (cipherId === undefined) {
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.CTAP2_ERR_OPERATION_DENIED);
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed);
}
const keyPair = await this.createKeyPair();
@@ -126,10 +129,10 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
fido2Key.keyType = "ECDSA";
fido2Key.keyCurve = "P-256";
fido2Key.keyValue = Fido2Utils.bufferToString(pcks8Key);
fido2Key.rpId = params.rp.id;
fido2Key.rpName = params.rp.name;
fido2Key.userHandle = Fido2Utils.bufferToString(params.user.id);
fido2Key.userName = params.user.name;
fido2Key.rpId = params.rpEntity.id;
fido2Key.rpName = params.rpEntity.name;
fido2Key.userHandle = Fido2Utils.bufferToString(params.userEntity.id);
fido2Key.userName = params.userEntity.name;
return fido2Key;
}