mirror of
https://github.com/bitwarden/browser
synced 2026-02-19 19:04:01 +00:00
[PM-3807] All passkeys as login ciphers - Minimal implementation to minimize blockers (#6233)
* [PM-3807] feat: remove non-discoverable from fido2 user interface class * [PM-3807] feat: merge fido2 component ui * [PM-3807] feat: return `cipherId` from user interface * [PM-3807] feat: merge credential creation logic in authenticator * [PM-3807] feat: merge credential assertion logic in authenticator --------- Co-authored-by: gbubemismith <gsmithwalter@gmail.com>
This commit is contained in:
@@ -64,17 +64,6 @@ export type BrowserFido2Message = { sessionId: string } & (
|
||||
}
|
||||
| {
|
||||
type: "ConfirmNewCredentialResponse";
|
||||
userVerified: boolean;
|
||||
}
|
||||
| {
|
||||
type: "ConfirmNewNonDiscoverableCredentialRequest";
|
||||
credentialName: string;
|
||||
userName: string;
|
||||
userVerification: boolean;
|
||||
fallbackSupported: boolean;
|
||||
}
|
||||
| {
|
||||
type: "ConfirmNewNonDiscoverableCredentialResponse";
|
||||
cipherId: string;
|
||||
userVerified: boolean;
|
||||
}
|
||||
@@ -213,7 +202,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
credentialName,
|
||||
userName,
|
||||
userVerification,
|
||||
}: NewCredentialParams): Promise<{ confirmed: boolean; userVerified: boolean }> {
|
||||
}: NewCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
|
||||
const data: BrowserFido2Message = {
|
||||
type: "ConfirmNewCredentialRequest",
|
||||
sessionId: this.sessionId,
|
||||
@@ -226,26 +215,6 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
||||
await this.send(data);
|
||||
const response = await this.receive("ConfirmNewCredentialResponse");
|
||||
|
||||
return { confirmed: true, userVerified: response.userVerified };
|
||||
}
|
||||
|
||||
async confirmNewNonDiscoverableCredential({
|
||||
credentialName,
|
||||
userName,
|
||||
userVerification,
|
||||
}: NewCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
|
||||
const data: BrowserFido2Message = {
|
||||
type: "ConfirmNewNonDiscoverableCredentialRequest",
|
||||
sessionId: this.sessionId,
|
||||
credentialName,
|
||||
userName,
|
||||
userVerification,
|
||||
fallbackSupported: this.fallbackSupported,
|
||||
};
|
||||
|
||||
await this.send(data);
|
||||
const response = await this.receive("ConfirmNewNonDiscoverableCredentialResponse");
|
||||
|
||||
return { cipherId: response.cipherId, userVerified: response.userVerified };
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<ng-container
|
||||
*ngIf="
|
||||
data.message.type == 'PickCredentialRequest' ||
|
||||
data.message.type == 'ConfirmNewNonDiscoverableCredentialRequest'
|
||||
data.message.type == 'ConfirmNewCredentialRequest'
|
||||
"
|
||||
>
|
||||
A site is asking for authentication, please choose one of the following credentials to use:
|
||||
@@ -24,15 +24,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="data.message.type == 'ConfirmNewCredentialRequest'">
|
||||
A site wants to create the following passkey in your vault
|
||||
<div class="box list">
|
||||
<div class="box-content">
|
||||
<app-cipher-row [cipher]="ciphers[0]"></app-cipher-row>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="confirm()">Create</button>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="data.message.type == 'InformExcludedCredentialRequest'">
|
||||
A passkey already exists in Bitwarden for this account
|
||||
<div class="box list">
|
||||
|
||||
@@ -16,7 +16,6 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
|
||||
import { PasswordRepromptService } from "@bitwarden/common/vault/abstractions/password-reprompt.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums/cipher-type";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { Fido2KeyView } from "@bitwarden/common/vault/models/view/fido2-key.view";
|
||||
|
||||
import { BrowserApi } from "../../../../platform/browser/browser-api";
|
||||
import {
|
||||
@@ -80,12 +79,9 @@ export class Fido2Component implements OnInit, OnDestroy {
|
||||
filter((message) => message != undefined),
|
||||
concatMap(async (message) => {
|
||||
if (message.type === "ConfirmNewCredentialRequest") {
|
||||
const cipher = new CipherView();
|
||||
cipher.name = message.credentialName;
|
||||
cipher.type = CipherType.Fido2Key;
|
||||
cipher.fido2Key = new Fido2KeyView();
|
||||
cipher.fido2Key.userDisplayName = message.userName;
|
||||
this.ciphers = [cipher];
|
||||
this.ciphers = (await this.cipherService.getAllDecrypted()).filter(
|
||||
(cipher) => cipher.type === CipherType.Login && !cipher.isDeleted
|
||||
);
|
||||
} else if (message.type === "PickCredentialRequest") {
|
||||
this.ciphers = await Promise.all(
|
||||
message.cipherIds.map(async (cipherId) => {
|
||||
@@ -93,10 +89,6 @@ export class Fido2Component implements OnInit, OnDestroy {
|
||||
return cipher.decrypt();
|
||||
})
|
||||
);
|
||||
} else if (message.type === "ConfirmNewNonDiscoverableCredentialRequest") {
|
||||
this.ciphers = (await this.cipherService.getAllDecrypted()).filter(
|
||||
(cipher) => cipher.type === CipherType.Login && !cipher.isDeleted
|
||||
);
|
||||
} else if (message.type === "InformExcludedCredentialRequest") {
|
||||
this.ciphers = await Promise.all(
|
||||
message.existingCipherIds.map(async (cipherId) => {
|
||||
@@ -140,7 +132,7 @@ export class Fido2Component implements OnInit, OnDestroy {
|
||||
type: "PickCredentialResponse",
|
||||
userVerified,
|
||||
});
|
||||
} else if (data?.type === "ConfirmNewNonDiscoverableCredentialRequest") {
|
||||
} else if (data?.type === "ConfirmNewCredentialRequest") {
|
||||
let userVerified = false;
|
||||
if (data.userVerification) {
|
||||
userVerified = await this.passwordRepromptService.showPasswordPrompt();
|
||||
@@ -149,7 +141,7 @@ export class Fido2Component implements OnInit, OnDestroy {
|
||||
this.send({
|
||||
sessionId: this.sessionId,
|
||||
cipherId: cipher.id,
|
||||
type: "ConfirmNewNonDiscoverableCredentialResponse",
|
||||
type: "ConfirmNewCredentialResponse",
|
||||
userVerified,
|
||||
});
|
||||
}
|
||||
@@ -157,25 +149,6 @@ export class Fido2Component implements OnInit, OnDestroy {
|
||||
this.loading = true;
|
||||
}
|
||||
|
||||
async confirm() {
|
||||
const data = this.message$.value;
|
||||
if (data.type !== "ConfirmNewCredentialRequest") {
|
||||
return;
|
||||
}
|
||||
|
||||
let userVerified = false;
|
||||
if (data.userVerification) {
|
||||
userVerified = await this.passwordRepromptService.showPasswordPrompt();
|
||||
}
|
||||
|
||||
this.send({
|
||||
sessionId: this.sessionId,
|
||||
type: "ConfirmNewCredentialResponse",
|
||||
userVerified,
|
||||
});
|
||||
this.loading = true;
|
||||
}
|
||||
|
||||
abort(fallback: boolean) {
|
||||
this.unload(fallback);
|
||||
window.close();
|
||||
|
||||
@@ -27,10 +27,6 @@ export abstract class Fido2UserInterfaceSession {
|
||||
confirmNewCredential: (
|
||||
params: NewCredentialParams,
|
||||
abortController?: AbortController
|
||||
) => Promise<{ confirmed: boolean; userVerified: boolean }>;
|
||||
confirmNewNonDiscoverableCredential: (
|
||||
params: NewCredentialParams,
|
||||
abortController?: AbortController
|
||||
) => Promise<{ cipherId: string; userVerified: boolean }>;
|
||||
informExcludedCredential: (
|
||||
existingCipherIds: string[],
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
|
||||
it("should not request confirmation from user", async () => {
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue({
|
||||
confirmed: true,
|
||||
cipherId: "75280e7e-a72e-4d6c-bf1e-d37238352f9b",
|
||||
userVerified: false,
|
||||
});
|
||||
const invalidParams = await createInvalidParams();
|
||||
@@ -256,110 +256,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("creation of discoverable credential", () => {
|
||||
let params: Fido2AuthenticatorMakeCredentialsParams;
|
||||
|
||||
beforeEach(async () => {
|
||||
params = await createParams({ requireResidentKey: true });
|
||||
cipherService.getAllDecrypted.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* If requireUserVerification is true, the authorization gesture MUST include user verification.
|
||||
* Deviation: Only `rpEntity.name` and `userEntity.name` is shown.
|
||||
* */
|
||||
for (const userVerification of [true, false]) {
|
||||
it(`should request confirmation from user when user verification is ${userVerification}`, async () => {
|
||||
params.requireUserVerification = userVerification;
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue({
|
||||
confirmed: true,
|
||||
userVerified: userVerification,
|
||||
});
|
||||
cipherService.encrypt.mockResolvedValue({} as unknown as Cipher);
|
||||
cipherService.createWithServer.mockImplementation(async (cipher) => {
|
||||
cipher.id = Utils.newGuid();
|
||||
return cipher;
|
||||
});
|
||||
|
||||
await authenticator.makeCredential(params, new AbortController());
|
||||
|
||||
expect(userInterfaceSession.confirmNewCredential).toHaveBeenCalledWith(
|
||||
{
|
||||
credentialName: params.rpEntity.name,
|
||||
userName: params.userEntity.displayName,
|
||||
userVerification,
|
||||
} as NewCredentialParams,
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("should save credential to vault if request confirmed by user", async () => {
|
||||
const encryptedCipher = {};
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue({
|
||||
confirmed: true,
|
||||
userVerified: false,
|
||||
});
|
||||
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher);
|
||||
cipherService.createWithServer.mockImplementation(async (cipher) => {
|
||||
cipher.id = Utils.newGuid();
|
||||
return cipher;
|
||||
});
|
||||
|
||||
await authenticator.makeCredential(params);
|
||||
|
||||
const saved = cipherService.encrypt.mock.lastCall?.[0];
|
||||
expect(saved).toEqual(
|
||||
expect.objectContaining({
|
||||
type: CipherType.Fido2Key,
|
||||
name: params.rpEntity.name,
|
||||
|
||||
fido2Key: expect.objectContaining({
|
||||
credentialId: expect.anything(),
|
||||
keyType: "public-key",
|
||||
keyAlgorithm: "ECDSA",
|
||||
keyCurve: "P-256",
|
||||
rpId: params.rpEntity.id,
|
||||
rpName: params.rpEntity.name,
|
||||
userHandle: Fido2Utils.bufferToString(params.userEntity.id),
|
||||
counter: 0,
|
||||
userDisplayName: params.userEntity.displayName,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(cipherService.createWithServer).toHaveBeenCalledWith(encryptedCipher);
|
||||
});
|
||||
|
||||
/** 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 () => {
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue({
|
||||
confirmed: false,
|
||||
userVerified: false,
|
||||
});
|
||||
|
||||
const result = async () => await authenticator.makeCredential(params);
|
||||
|
||||
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.NotAllowed);
|
||||
});
|
||||
|
||||
/** Spec: If any error occurred while creating the new credential object, return an error code equivalent to "UnknownError" and terminate the operation. */
|
||||
it("should throw unkown error if creation fails", async () => {
|
||||
const encryptedCipher = {};
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue({
|
||||
confirmed: true,
|
||||
userVerified: false,
|
||||
});
|
||||
cipherService.encrypt.mockResolvedValue(encryptedCipher as unknown as Cipher);
|
||||
cipherService.createWithServer.mockRejectedValue(new Error("Internal error"));
|
||||
|
||||
const result = async () => await authenticator.makeCredential(params);
|
||||
|
||||
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown);
|
||||
});
|
||||
});
|
||||
|
||||
describe("creation of non-discoverable credential", () => {
|
||||
describe("credential creation", () => {
|
||||
let existingCipher: CipherView;
|
||||
let params: Fido2AuthenticatorMakeCredentialsParams;
|
||||
|
||||
@@ -379,14 +276,14 @@ describe("FidoAuthenticatorService", () => {
|
||||
for (const userVerification of [true, false]) {
|
||||
it(`should request confirmation from user when user verification is ${userVerification}`, async () => {
|
||||
params.requireUserVerification = userVerification;
|
||||
userInterfaceSession.confirmNewNonDiscoverableCredential.mockResolvedValue({
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue({
|
||||
cipherId: existingCipher.id,
|
||||
userVerified: userVerification,
|
||||
});
|
||||
|
||||
await authenticator.makeCredential(params, new AbortController());
|
||||
|
||||
expect(userInterfaceSession.confirmNewNonDiscoverableCredential).toHaveBeenCalledWith(
|
||||
expect(userInterfaceSession.confirmNewCredential).toHaveBeenCalledWith(
|
||||
{
|
||||
credentialName: params.rpEntity.name,
|
||||
userName: params.userEntity.displayName,
|
||||
@@ -399,7 +296,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
|
||||
it("should save credential to vault if request confirmed by user", async () => {
|
||||
const encryptedCipher = Symbol();
|
||||
userInterfaceSession.confirmNewNonDiscoverableCredential.mockResolvedValue({
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue({
|
||||
cipherId: existingCipher.id,
|
||||
userVerified: false,
|
||||
});
|
||||
@@ -433,7 +330,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
|
||||
/** 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 () => {
|
||||
userInterfaceSession.confirmNewNonDiscoverableCredential.mockResolvedValue({
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue({
|
||||
cipherId: undefined,
|
||||
userVerified: false,
|
||||
});
|
||||
@@ -447,7 +344,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
/** Spec: If any error occurred while creating the new credential object, return an error code equivalent to "UnknownError" and terminate the operation. */
|
||||
it("should throw unkown error if creation fails", async () => {
|
||||
const encryptedCipher = Symbol();
|
||||
userInterfaceSession.confirmNewNonDiscoverableCredential.mockResolvedValue({
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue({
|
||||
cipherId: existingCipher.id,
|
||||
userVerified: false,
|
||||
});
|
||||
@@ -460,86 +357,74 @@ describe("FidoAuthenticatorService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
for (const requireResidentKey of [true, false]) {
|
||||
describe(`attestation of new ${
|
||||
requireResidentKey ? "discoverable" : "non-discoverable"
|
||||
} credential`, () => {
|
||||
const cipherId = "75280e7e-a72e-4d6c-bf1e-d37238352f9b";
|
||||
const credentialId = "52217b91-73f1-4fea-b3f2-54a7959fd5aa";
|
||||
const credentialIdBytes = new Uint8Array([
|
||||
0x52, 0x21, 0x7b, 0x91, 0x73, 0xf1, 0x4f, 0xea, 0xb3, 0xf2, 0x54, 0xa7, 0x95, 0x9f, 0xd5,
|
||||
0xaa,
|
||||
]);
|
||||
let params: Fido2AuthenticatorMakeCredentialsParams;
|
||||
describe(`attestation of new credential`, () => {
|
||||
const cipherId = "75280e7e-a72e-4d6c-bf1e-d37238352f9b";
|
||||
const credentialId = "52217b91-73f1-4fea-b3f2-54a7959fd5aa";
|
||||
const credentialIdBytes = new Uint8Array([
|
||||
0x52, 0x21, 0x7b, 0x91, 0x73, 0xf1, 0x4f, 0xea, 0xb3, 0xf2, 0x54, 0xa7, 0x95, 0x9f, 0xd5,
|
||||
0xaa,
|
||||
]);
|
||||
let params: Fido2AuthenticatorMakeCredentialsParams;
|
||||
|
||||
beforeEach(async () => {
|
||||
const cipher = createCipherView({ id: cipherId, type: CipherType.Login });
|
||||
params = await createParams({ requireResidentKey });
|
||||
userInterfaceSession.confirmNewNonDiscoverableCredential.mockResolvedValue({
|
||||
cipherId,
|
||||
userVerified: false,
|
||||
});
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue({
|
||||
confirmed: true,
|
||||
userVerified: false,
|
||||
});
|
||||
cipherService.get.mockImplementation(async (cipherId) =>
|
||||
cipherId === cipher.id ? ({ decrypt: () => cipher } as any) : undefined
|
||||
);
|
||||
cipherService.getAllDecrypted.mockResolvedValue([await cipher]);
|
||||
cipherService.encrypt.mockImplementation(async (cipher) => {
|
||||
if (!requireResidentKey) {
|
||||
cipher.login.fido2Key.credentialId = credentialId; // Replace id for testability
|
||||
} else {
|
||||
cipher.fido2Key.credentialId = credentialId;
|
||||
}
|
||||
return {} as any;
|
||||
});
|
||||
cipherService.createWithServer.mockImplementation(async (cipher) => {
|
||||
cipher.id = cipherId;
|
||||
return cipher;
|
||||
});
|
||||
cipherService.updateWithServer.mockImplementation(async (cipher) => {
|
||||
cipher.id = cipherId;
|
||||
return cipher;
|
||||
});
|
||||
beforeEach(async () => {
|
||||
const cipher = createCipherView({ id: cipherId, type: CipherType.Login });
|
||||
params = await createParams();
|
||||
userInterfaceSession.confirmNewCredential.mockResolvedValue({
|
||||
cipherId,
|
||||
userVerified: false,
|
||||
});
|
||||
|
||||
it("should return attestation object", async () => {
|
||||
const result = await authenticator.makeCredential(params);
|
||||
|
||||
const attestationObject = CBOR.decode(
|
||||
Fido2Utils.bufferSourceToUint8Array(result.attestationObject).buffer
|
||||
);
|
||||
|
||||
const encAuthData: Uint8Array = attestationObject.authData;
|
||||
const rpIdHash = encAuthData.slice(0, 32);
|
||||
const flags = encAuthData.slice(32, 33);
|
||||
const counter = encAuthData.slice(33, 37);
|
||||
const aaguid = encAuthData.slice(37, 53);
|
||||
const credentialIdLength = encAuthData.slice(53, 55);
|
||||
const credentialId = encAuthData.slice(55, 71);
|
||||
// Unsure how to test public key
|
||||
// const publicKey = encAuthData.slice(87);
|
||||
|
||||
expect(encAuthData.length).toBe(71 + 77);
|
||||
expect(attestationObject.fmt).toBe("none");
|
||||
expect(attestationObject.attStmt).toEqual({});
|
||||
expect(rpIdHash).toEqual(
|
||||
new Uint8Array([
|
||||
0x22, 0x6b, 0xb3, 0x92, 0x02, 0xff, 0xf9, 0x22, 0xdc, 0x74, 0x05, 0xcd, 0x28, 0xa8,
|
||||
0x34, 0x5a, 0xc4, 0xf2, 0x64, 0x51, 0xd7, 0x3d, 0x0b, 0x40, 0xef, 0xf3, 0x1d, 0xc1,
|
||||
0xd0, 0x5c, 0x3d, 0xc3,
|
||||
])
|
||||
);
|
||||
expect(flags).toEqual(new Uint8Array([0b01000001])); // UP = true, AD = true
|
||||
expect(counter).toEqual(new Uint8Array([0, 0, 0, 0])); // 0 because of new counter
|
||||
expect(aaguid).toEqual(AAGUID);
|
||||
expect(credentialIdLength).toEqual(new Uint8Array([0, 16])); // 16 bytes because we're using GUIDs
|
||||
expect(credentialId).toEqual(credentialIdBytes);
|
||||
cipherService.get.mockImplementation(async (cipherId) =>
|
||||
cipherId === cipher.id ? ({ decrypt: () => cipher } as any) : undefined
|
||||
);
|
||||
cipherService.getAllDecrypted.mockResolvedValue([await cipher]);
|
||||
cipherService.encrypt.mockImplementation(async (cipher) => {
|
||||
cipher.login.fido2Key.credentialId = credentialId; // Replace id for testability
|
||||
return {} as any;
|
||||
});
|
||||
cipherService.createWithServer.mockImplementation(async (cipher) => {
|
||||
cipher.id = cipherId;
|
||||
return cipher;
|
||||
});
|
||||
cipherService.updateWithServer.mockImplementation(async (cipher) => {
|
||||
cipher.id = cipherId;
|
||||
return cipher;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it("should return attestation object", async () => {
|
||||
const result = await authenticator.makeCredential(params);
|
||||
|
||||
const attestationObject = CBOR.decode(
|
||||
Fido2Utils.bufferSourceToUint8Array(result.attestationObject).buffer
|
||||
);
|
||||
|
||||
const encAuthData: Uint8Array = attestationObject.authData;
|
||||
const rpIdHash = encAuthData.slice(0, 32);
|
||||
const flags = encAuthData.slice(32, 33);
|
||||
const counter = encAuthData.slice(33, 37);
|
||||
const aaguid = encAuthData.slice(37, 53);
|
||||
const credentialIdLength = encAuthData.slice(53, 55);
|
||||
const credentialId = encAuthData.slice(55, 71);
|
||||
// Unsure how to test public key
|
||||
// const publicKey = encAuthData.slice(87);
|
||||
|
||||
expect(encAuthData.length).toBe(71 + 77);
|
||||
expect(attestationObject.fmt).toBe("none");
|
||||
expect(attestationObject.attStmt).toEqual({});
|
||||
expect(rpIdHash).toEqual(
|
||||
new Uint8Array([
|
||||
0x22, 0x6b, 0xb3, 0x92, 0x02, 0xff, 0xf9, 0x22, 0xdc, 0x74, 0x05, 0xcd, 0x28, 0xa8,
|
||||
0x34, 0x5a, 0xc4, 0xf2, 0x64, 0x51, 0xd7, 0x3d, 0x0b, 0x40, 0xef, 0xf3, 0x1d, 0xc1,
|
||||
0xd0, 0x5c, 0x3d, 0xc3,
|
||||
])
|
||||
);
|
||||
expect(flags).toEqual(new Uint8Array([0b01000001])); // UP = true, AD = true
|
||||
expect(counter).toEqual(new Uint8Array([0, 0, 0, 0])); // 0 because of new counter
|
||||
expect(aaguid).toEqual(AAGUID);
|
||||
expect(credentialIdLength).toEqual(new Uint8Array([0, 16])); // 16 bytes because we're using GUIDs
|
||||
expect(credentialId).toEqual(credentialIdBytes);
|
||||
});
|
||||
});
|
||||
|
||||
async function createParams(
|
||||
params: Partial<Fido2AuthenticatorMakeCredentialsParams> = {}
|
||||
@@ -701,7 +586,7 @@ describe("FidoAuthenticatorService", () => {
|
||||
{ credentialId: credentialIds[0], rpId: RpId }
|
||||
),
|
||||
await createCipherView(
|
||||
{ type: CipherType.Fido2Key },
|
||||
{ type: CipherType.Login },
|
||||
{ credentialId: credentialIds[1], rpId: RpId }
|
||||
),
|
||||
];
|
||||
@@ -746,157 +631,128 @@ describe("FidoAuthenticatorService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
for (const residentKey of [true, false]) {
|
||||
describe(`assertion of ${
|
||||
residentKey ? "discoverable" : "non-discoverable"
|
||||
} credential`, () => {
|
||||
let keyPair: CryptoKeyPair;
|
||||
let credentialIds: string[];
|
||||
let selectedCredentialId: string;
|
||||
let ciphers: CipherView[];
|
||||
let fido2Keys: Fido2KeyView[];
|
||||
let params: Fido2AuthenticatorGetAssertionParams;
|
||||
describe("assertion of credential", () => {
|
||||
let keyPair: CryptoKeyPair;
|
||||
let credentialIds: string[];
|
||||
let selectedCredentialId: string;
|
||||
let ciphers: CipherView[];
|
||||
let fido2Keys: Fido2KeyView[];
|
||||
let params: Fido2AuthenticatorGetAssertionParams;
|
||||
|
||||
const init = async () => {
|
||||
keyPair = await createKeyPair();
|
||||
credentialIds = [Utils.newGuid(), Utils.newGuid()];
|
||||
const keyValue = Fido2Utils.bufferToString(
|
||||
await crypto.subtle.exportKey("pkcs8", keyPair.privateKey)
|
||||
);
|
||||
if (residentKey) {
|
||||
ciphers = credentialIds.map((id) =>
|
||||
createCipherView(
|
||||
{ type: CipherType.Fido2Key },
|
||||
{ credentialId: id, rpId: RpId, counter: 9000, keyValue }
|
||||
)
|
||||
);
|
||||
fido2Keys = ciphers.map((c) => c.fido2Key);
|
||||
selectedCredentialId = credentialIds[0];
|
||||
params = await createParams({
|
||||
allowCredentialDescriptorList: undefined,
|
||||
rpId: RpId,
|
||||
});
|
||||
} else {
|
||||
ciphers = credentialIds.map((id) =>
|
||||
createCipherView(
|
||||
{ type: CipherType.Login },
|
||||
{ credentialId: id, rpId: RpId, counter: 9000 }
|
||||
)
|
||||
);
|
||||
fido2Keys = ciphers.map((c) => c.login.fido2Key);
|
||||
selectedCredentialId = credentialIds[0];
|
||||
params = await createParams({
|
||||
allowCredentialDescriptorList: credentialIds.map((credentialId) => ({
|
||||
id: guidToRawFormat(credentialId),
|
||||
type: "public-key",
|
||||
})),
|
||||
rpId: RpId,
|
||||
});
|
||||
}
|
||||
cipherService.getAllDecrypted.mockResolvedValue(ciphers);
|
||||
userInterfaceSession.pickCredential.mockResolvedValue({
|
||||
cipherId: ciphers[0].id,
|
||||
userVerified: false,
|
||||
});
|
||||
};
|
||||
beforeEach(init);
|
||||
|
||||
/** Spec: Increment the credential associated signature counter */
|
||||
it("should increment counter", async () => {
|
||||
const encrypted = Symbol();
|
||||
cipherService.encrypt.mockResolvedValue(encrypted as any);
|
||||
|
||||
await authenticator.getAssertion(params);
|
||||
|
||||
expect(cipherService.updateWithServer).toHaveBeenCalledWith(encrypted);
|
||||
if (residentKey) {
|
||||
expect(cipherService.encrypt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: ciphers[0].id,
|
||||
fido2Key: expect.objectContaining({
|
||||
counter: 9001,
|
||||
}),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
expect(cipherService.encrypt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: ciphers[0].id,
|
||||
login: expect.objectContaining({
|
||||
fido2Key: expect.objectContaining({
|
||||
counter: 9001,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
const init = async () => {
|
||||
keyPair = await createKeyPair();
|
||||
credentialIds = [Utils.newGuid(), Utils.newGuid()];
|
||||
const keyValue = Fido2Utils.bufferToString(
|
||||
await crypto.subtle.exportKey("pkcs8", keyPair.privateKey)
|
||||
);
|
||||
ciphers = credentialIds.map((id) =>
|
||||
createCipherView(
|
||||
{ type: CipherType.Login },
|
||||
{ credentialId: id, rpId: RpId, counter: 9000, keyValue }
|
||||
)
|
||||
);
|
||||
fido2Keys = ciphers.map((c) => c.login.fido2Key);
|
||||
selectedCredentialId = credentialIds[0];
|
||||
params = await createParams({
|
||||
allowCredentialDescriptorList: credentialIds.map((credentialId) => ({
|
||||
id: guidToRawFormat(credentialId),
|
||||
type: "public-key",
|
||||
})),
|
||||
rpId: RpId,
|
||||
});
|
||||
cipherService.getAllDecrypted.mockResolvedValue(ciphers);
|
||||
userInterfaceSession.pickCredential.mockResolvedValue({
|
||||
cipherId: ciphers[0].id,
|
||||
userVerified: false,
|
||||
});
|
||||
};
|
||||
beforeEach(init);
|
||||
|
||||
it("should return an assertion result", async () => {
|
||||
/** Spec: Increment the credential associated signature counter */
|
||||
it("should increment counter", async () => {
|
||||
const encrypted = Symbol();
|
||||
cipherService.encrypt.mockResolvedValue(encrypted as any);
|
||||
|
||||
await authenticator.getAssertion(params);
|
||||
|
||||
expect(cipherService.updateWithServer).toHaveBeenCalledWith(encrypted);
|
||||
|
||||
expect(cipherService.encrypt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: ciphers[0].id,
|
||||
login: expect.objectContaining({
|
||||
fido2Key: expect.objectContaining({
|
||||
counter: 9001,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should return an assertion result", async () => {
|
||||
const result = await authenticator.getAssertion(params);
|
||||
|
||||
const encAuthData = result.authenticatorData;
|
||||
const rpIdHash = encAuthData.slice(0, 32);
|
||||
const flags = encAuthData.slice(32, 33);
|
||||
const counter = encAuthData.slice(33, 37);
|
||||
|
||||
expect(result.selectedCredential.id).toEqual(guidToRawFormat(selectedCredentialId));
|
||||
expect(result.selectedCredential.userHandle).toEqual(
|
||||
Fido2Utils.stringToBuffer(fido2Keys[0].userHandle)
|
||||
);
|
||||
expect(rpIdHash).toEqual(
|
||||
new Uint8Array([
|
||||
0x22, 0x6b, 0xb3, 0x92, 0x02, 0xff, 0xf9, 0x22, 0xdc, 0x74, 0x05, 0xcd, 0x28, 0xa8,
|
||||
0x34, 0x5a, 0xc4, 0xf2, 0x64, 0x51, 0xd7, 0x3d, 0x0b, 0x40, 0xef, 0xf3, 0x1d, 0xc1,
|
||||
0xd0, 0x5c, 0x3d, 0xc3,
|
||||
])
|
||||
);
|
||||
expect(flags).toEqual(new Uint8Array([0b00000001])); // UP = true
|
||||
expect(counter).toEqual(new Uint8Array([0, 0, 0x23, 0x29])); // 9001 in hex
|
||||
|
||||
// Verify signature
|
||||
// TODO: Cannot verify signature because it has been converted into DER format
|
||||
// const sigBase = new Uint8Array([
|
||||
// ...result.authenticatorData,
|
||||
// ...Fido2Utils.bufferSourceToUint8Array(params.hash),
|
||||
// ]);
|
||||
// const isValidSignature = await crypto.subtle.verify(
|
||||
// { name: "ECDSA", hash: { name: "SHA-256" } },
|
||||
// keyPair.publicKey,
|
||||
// result.signature,
|
||||
// sigBase
|
||||
// );
|
||||
// expect(isValidSignature).toBe(true);
|
||||
});
|
||||
|
||||
it("should always generate unique signatures even if the input is the same", async () => {
|
||||
const signatures = new Set();
|
||||
|
||||
for (let i = 0; i < 10; ++i) {
|
||||
await init(); // Reset inputs
|
||||
const result = await authenticator.getAssertion(params);
|
||||
|
||||
const encAuthData = result.authenticatorData;
|
||||
const rpIdHash = encAuthData.slice(0, 32);
|
||||
const flags = encAuthData.slice(32, 33);
|
||||
const counter = encAuthData.slice(33, 37);
|
||||
const counter = result.authenticatorData.slice(33, 37);
|
||||
expect(counter).toEqual(new Uint8Array([0, 0, 0x23, 0x29])); // double check that the counter doesn't change
|
||||
|
||||
expect(result.selectedCredential.id).toEqual(guidToRawFormat(selectedCredentialId));
|
||||
expect(result.selectedCredential.userHandle).toEqual(
|
||||
Fido2Utils.stringToBuffer(fido2Keys[0].userHandle)
|
||||
);
|
||||
expect(rpIdHash).toEqual(
|
||||
new Uint8Array([
|
||||
0x22, 0x6b, 0xb3, 0x92, 0x02, 0xff, 0xf9, 0x22, 0xdc, 0x74, 0x05, 0xcd, 0x28, 0xa8,
|
||||
0x34, 0x5a, 0xc4, 0xf2, 0x64, 0x51, 0xd7, 0x3d, 0x0b, 0x40, 0xef, 0xf3, 0x1d, 0xc1,
|
||||
0xd0, 0x5c, 0x3d, 0xc3,
|
||||
])
|
||||
);
|
||||
expect(flags).toEqual(new Uint8Array([0b00000001])); // UP = true
|
||||
expect(counter).toEqual(new Uint8Array([0, 0, 0x23, 0x29])); // 9001 in hex
|
||||
|
||||
// Verify signature
|
||||
// TODO: Cannot verify signature because it has been converted into DER format
|
||||
// const sigBase = new Uint8Array([
|
||||
// ...result.authenticatorData,
|
||||
// ...Fido2Utils.bufferSourceToUint8Array(params.hash),
|
||||
// ]);
|
||||
// const isValidSignature = await crypto.subtle.verify(
|
||||
// { name: "ECDSA", hash: { name: "SHA-256" } },
|
||||
// keyPair.publicKey,
|
||||
// result.signature,
|
||||
// sigBase
|
||||
// );
|
||||
// expect(isValidSignature).toBe(true);
|
||||
});
|
||||
|
||||
it("should always generate unique signatures even if the input is the same", async () => {
|
||||
const signatures = new Set();
|
||||
|
||||
for (let i = 0; i < 10; ++i) {
|
||||
await init(); // Reset inputs
|
||||
const result = await authenticator.getAssertion(params);
|
||||
|
||||
const counter = result.authenticatorData.slice(33, 37);
|
||||
expect(counter).toEqual(new Uint8Array([0, 0, 0x23, 0x29])); // double check that the counter doesn't change
|
||||
|
||||
const signature = Fido2Utils.bufferToString(result.signature);
|
||||
if (signatures.has(signature)) {
|
||||
throw new Error("Found duplicate signature");
|
||||
}
|
||||
signatures.add(signature);
|
||||
const signature = Fido2Utils.bufferToString(result.signature);
|
||||
if (signatures.has(signature)) {
|
||||
throw new Error("Found duplicate signature");
|
||||
}
|
||||
});
|
||||
|
||||
/** Spec: If any error occurred while generating the assertion signature, return an error code equivalent to "UnknownError" and terminate the operation. */
|
||||
it("should throw unkown error if creation fails", async () => {
|
||||
cipherService.updateWithServer.mockRejectedValue(new Error("Internal error"));
|
||||
|
||||
const result = async () => await authenticator.getAssertion(params);
|
||||
|
||||
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown);
|
||||
});
|
||||
signatures.add(signature);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Spec: If any error occurred while generating the assertion signature, return an error code equivalent to "UnknownError" and terminate the operation. */
|
||||
it("should throw unkown error if creation fails", async () => {
|
||||
cipherService.updateWithServer.mockRejectedValue(new Error("Internal error"));
|
||||
|
||||
const result = async () => await authenticator.getAssertion(params);
|
||||
|
||||
await expect(result).rejects.toThrowError(Fido2AutenticatorErrorCode.Unknown);
|
||||
});
|
||||
});
|
||||
|
||||
async function createParams(
|
||||
params: Partial<Fido2AuthenticatorGetAssertionParams> = {}
|
||||
|
||||
@@ -97,90 +97,45 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
|
||||
let keyPair: CryptoKeyPair;
|
||||
let userVerified = false;
|
||||
let credentialId: string;
|
||||
if (params.requireResidentKey) {
|
||||
const response = await userInterfaceSession.confirmNewCredential(
|
||||
{
|
||||
credentialName: params.rpEntity.name,
|
||||
userName: params.userEntity.displayName,
|
||||
userVerification: params.requireUserVerification,
|
||||
},
|
||||
abortController
|
||||
const response = await userInterfaceSession.confirmNewCredential(
|
||||
{
|
||||
credentialName: params.rpEntity.name,
|
||||
userName: params.userEntity.displayName,
|
||||
userVerification: params.requireUserVerification,
|
||||
},
|
||||
abortController
|
||||
);
|
||||
const cipherId = response.cipherId;
|
||||
userVerified = response.userVerified;
|
||||
|
||||
if (cipherId === undefined) {
|
||||
this.logService?.warning(
|
||||
`[Fido2Authenticator] Aborting because user confirmation was not recieved.`
|
||||
);
|
||||
const userConfirmation = response.confirmed;
|
||||
userVerified = response.userVerified;
|
||||
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed);
|
||||
}
|
||||
|
||||
if (!userConfirmation) {
|
||||
this.logService?.warning(
|
||||
`[Fido2Authenticator] Aborting because user confirmation was not recieved.`
|
||||
);
|
||||
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed);
|
||||
}
|
||||
|
||||
if (params.requireUserVerification && !userVerified) {
|
||||
this.logService?.warning(
|
||||
`[Fido2Authenticator] Aborting because user verification was not successful.`
|
||||
);
|
||||
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed);
|
||||
}
|
||||
|
||||
try {
|
||||
keyPair = await createKeyPair();
|
||||
|
||||
cipher = new CipherView();
|
||||
cipher.type = CipherType.Fido2Key;
|
||||
cipher.name = params.rpEntity.name;
|
||||
cipher.fido2Key = fido2Key = await createKeyView(params, keyPair.privateKey);
|
||||
const encrypted = await this.cipherService.encrypt(cipher);
|
||||
await this.cipherService.createWithServer(encrypted); // encrypted.id is assigned inside here
|
||||
cipher.id = encrypted.id;
|
||||
credentialId = cipher.fido2Key.credentialId;
|
||||
} catch (error) {
|
||||
this.logService?.error(
|
||||
`[Fido2Authenticator] Aborting because of unknown error when creating discoverable credential: ${error}`
|
||||
);
|
||||
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown);
|
||||
}
|
||||
} else {
|
||||
const response = await userInterfaceSession.confirmNewNonDiscoverableCredential(
|
||||
{
|
||||
credentialName: params.rpEntity.name,
|
||||
userName: params.userEntity.displayName,
|
||||
userVerification: params.requireUserVerification,
|
||||
},
|
||||
abortController
|
||||
if (params.requireUserVerification && !userVerified) {
|
||||
this.logService?.warning(
|
||||
`[Fido2Authenticator] Aborting because user verification was unsuccessful.`
|
||||
);
|
||||
const cipherId = response.cipherId;
|
||||
userVerified = response.userVerified;
|
||||
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed);
|
||||
}
|
||||
|
||||
if (cipherId === undefined) {
|
||||
this.logService?.warning(
|
||||
`[Fido2Authenticator] Aborting because user confirmation was not recieved.`
|
||||
);
|
||||
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed);
|
||||
}
|
||||
try {
|
||||
keyPair = await createKeyPair();
|
||||
|
||||
if (params.requireUserVerification && !userVerified) {
|
||||
this.logService?.warning(
|
||||
`[Fido2Authenticator] Aborting because user verification was unsuccessful.`
|
||||
);
|
||||
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed);
|
||||
}
|
||||
|
||||
try {
|
||||
keyPair = await createKeyPair();
|
||||
|
||||
const encrypted = await this.cipherService.get(cipherId);
|
||||
cipher = await encrypted.decrypt();
|
||||
cipher.login.fido2Key = fido2Key = await createKeyView(params, keyPair.privateKey);
|
||||
const reencrypted = await this.cipherService.encrypt(cipher);
|
||||
await this.cipherService.updateWithServer(reencrypted);
|
||||
credentialId = cipher.login.fido2Key.credentialId;
|
||||
} catch (error) {
|
||||
this.logService?.error(
|
||||
`[Fido2Authenticator] Aborting because of unknown error when creating non-discoverable credential: ${error}`
|
||||
);
|
||||
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown);
|
||||
}
|
||||
const encrypted = await this.cipherService.get(cipherId);
|
||||
cipher = await encrypted.decrypt();
|
||||
cipher.login.fido2Key = fido2Key = await createKeyView(params, keyPair.privateKey);
|
||||
const reencrypted = await this.cipherService.encrypt(cipher);
|
||||
await this.cipherService.updateWithServer(reencrypted);
|
||||
credentialId = cipher.login.fido2Key.credentialId;
|
||||
} catch (error) {
|
||||
this.logService?.error(
|
||||
`[Fido2Authenticator] Aborting because of unknown error when creating credential: ${error}`
|
||||
);
|
||||
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.Unknown);
|
||||
}
|
||||
|
||||
const authData = await generateAuthData({
|
||||
@@ -374,14 +329,11 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
|
||||
const ciphers = await this.cipherService.getAllDecrypted();
|
||||
return ciphers.filter(
|
||||
(cipher) =>
|
||||
(!cipher.isDeleted &&
|
||||
cipher.type === CipherType.Login &&
|
||||
cipher.login.fido2Key != undefined &&
|
||||
cipher.login.fido2Key.rpId === rpId &&
|
||||
ids.includes(cipher.login.fido2Key.credentialId)) ||
|
||||
(cipher.type === CipherType.Fido2Key &&
|
||||
cipher.fido2Key.rpId === rpId &&
|
||||
ids.includes(cipher.fido2Key.credentialId))
|
||||
!cipher.isDeleted &&
|
||||
cipher.type === CipherType.Login &&
|
||||
cipher.login.fido2Key != undefined &&
|
||||
cipher.login.fido2Key.rpId === rpId &&
|
||||
ids.includes(cipher.login.fido2Key.credentialId)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -389,7 +341,10 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
|
||||
const ciphers = await this.cipherService.getAllDecrypted();
|
||||
return ciphers.filter(
|
||||
(cipher) =>
|
||||
!cipher.isDeleted && cipher.type === CipherType.Fido2Key && cipher.fido2Key.rpId === rpId
|
||||
!cipher.isDeleted &&
|
||||
cipher.type === CipherType.Login &&
|
||||
cipher.login.fido2Key != undefined &&
|
||||
cipher.login.fido2Key.rpId === rpId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user