mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 16:23:44 +00:00
[EC-598] feat: add support for user verifiction using MP during assertion
This commit is contained in:
@@ -1,17 +1,6 @@
|
|||||||
<ng-container *ngIf="data$ | async as data">
|
<ng-container *ngIf="data$ | async as data">
|
||||||
<div class="auth-wrapper">
|
<div class="auth-wrapper">
|
||||||
<i class="bwi bwi-spinner bwi-lg bwi-spin" [hidden]="!loading" aria-hidden="true"></i>
|
<i class="bwi bwi-spinner bwi-lg bwi-spin" [hidden]="!loading" aria-hidden="true"></i>
|
||||||
<ng-container *ngIf="data.type == 'ConfirmCredentialRequest'">
|
|
||||||
A site is asking for authentication using the following credential:
|
|
||||||
<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()">
|
|
||||||
Authenticate
|
|
||||||
</button>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container
|
<ng-container
|
||||||
*ngIf="
|
*ngIf="
|
||||||
data.type == 'PickCredentialRequest' ||
|
data.type == 'PickCredentialRequest' ||
|
||||||
@@ -36,7 +25,7 @@
|
|||||||
<app-cipher-row [cipher]="ciphers[0]"></app-cipher-row>
|
<app-cipher-row [cipher]="ciphers[0]"></app-cipher-row>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-outline-secondary" (click)="confirmNew()">Create</button>
|
<button type="button" class="btn btn-outline-secondary" (click)="confirm()">Create</button>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container *ngIf="data.type == 'InformExcludedCredentialRequest'">
|
<ng-container *ngIf="data.type == 'InformExcludedCredentialRequest'">
|
||||||
A passkey already exists in Bitwarden for this account
|
A passkey already exists in Bitwarden for this account
|
||||||
|
|||||||
@@ -77,9 +77,6 @@ export class Fido2Component implements OnInit, OnDestroy {
|
|||||||
cipher.fido2Key = new Fido2KeyView();
|
cipher.fido2Key = new Fido2KeyView();
|
||||||
cipher.fido2Key.userName = data.userName;
|
cipher.fido2Key.userName = data.userName;
|
||||||
this.ciphers = [cipher];
|
this.ciphers = [cipher];
|
||||||
} else if (data?.type === "ConfirmCredentialRequest") {
|
|
||||||
const cipher = await this.cipherService.get(data.cipherId);
|
|
||||||
this.ciphers = [await cipher.decrypt()];
|
|
||||||
} else if (data?.type === "PickCredentialRequest") {
|
} else if (data?.type === "PickCredentialRequest") {
|
||||||
this.ciphers = await Promise.all(
|
this.ciphers = await Promise.all(
|
||||||
data.cipherIds.map(async (cipherId) => {
|
data.cipherIds.map(async (cipherId) => {
|
||||||
@@ -117,10 +114,16 @@ export class Fido2Component implements OnInit, OnDestroy {
|
|||||||
async pick(cipher: CipherView) {
|
async pick(cipher: CipherView) {
|
||||||
const data = this.data$.value;
|
const data = this.data$.value;
|
||||||
if (data?.type === "PickCredentialRequest") {
|
if (data?.type === "PickCredentialRequest") {
|
||||||
|
let userVerified = false;
|
||||||
|
if (data.userVerification) {
|
||||||
|
userVerified = await this.passwordRepromptService.showPasswordPrompt();
|
||||||
|
}
|
||||||
|
|
||||||
this.send({
|
this.send({
|
||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
cipherId: cipher.id,
|
cipherId: cipher.id,
|
||||||
type: "PickCredentialResponse",
|
type: "PickCredentialResponse",
|
||||||
|
userVerified,
|
||||||
});
|
});
|
||||||
} else if (data?.type === "ConfirmNewNonDiscoverableCredentialRequest") {
|
} else if (data?.type === "ConfirmNewNonDiscoverableCredentialRequest") {
|
||||||
let userVerified = false;
|
let userVerified = false;
|
||||||
@@ -139,15 +142,7 @@ export class Fido2Component implements OnInit, OnDestroy {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
confirm() {
|
async confirm() {
|
||||||
this.send({
|
|
||||||
sessionId: this.sessionId,
|
|
||||||
type: "ConfirmCredentialResponse",
|
|
||||||
});
|
|
||||||
this.loading = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async confirmNew() {
|
|
||||||
const data = this.data$.value;
|
const data = this.data$.value;
|
||||||
if (data.type !== "ConfirmNewCredentialRequest") {
|
if (data.type !== "ConfirmNewCredentialRequest") {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction,
|
Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction,
|
||||||
Fido2UserInterfaceSession,
|
Fido2UserInterfaceSession,
|
||||||
NewCredentialParams,
|
NewCredentialParams,
|
||||||
|
PickCredentialParams,
|
||||||
} from "@bitwarden/common/fido2/abstractions/fido2-user-interface.service.abstraction";
|
} from "@bitwarden/common/fido2/abstractions/fido2-user-interface.service.abstraction";
|
||||||
import { Utils } from "@bitwarden/common/misc/utils";
|
import { Utils } from "@bitwarden/common/misc/utils";
|
||||||
|
|
||||||
@@ -46,17 +47,12 @@ export type BrowserFido2Message = { sessionId: string } & (
|
|||||||
| {
|
| {
|
||||||
type: "PickCredentialRequest";
|
type: "PickCredentialRequest";
|
||||||
cipherIds: string[];
|
cipherIds: string[];
|
||||||
|
userVerification: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "PickCredentialResponse";
|
type: "PickCredentialResponse";
|
||||||
cipherId?: string;
|
cipherId?: string;
|
||||||
}
|
userVerified: boolean;
|
||||||
| {
|
|
||||||
type: "ConfirmCredentialRequest";
|
|
||||||
cipherId: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "ConfirmCredentialResponse";
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "ConfirmNewCredentialRequest";
|
type: "ConfirmNewCredentialRequest";
|
||||||
@@ -179,30 +175,21 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
|
|||||||
return this.abortController.signal.aborted;
|
return this.abortController.signal.aborted;
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirmCredential(cipherId: string): Promise<boolean> {
|
async pickCredential({
|
||||||
const data: BrowserFido2Message = {
|
cipherIds,
|
||||||
type: "ConfirmCredentialRequest",
|
userVerification,
|
||||||
cipherId,
|
}: PickCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
|
||||||
sessionId: this.sessionId,
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.send(data);
|
|
||||||
await this.receive("ConfirmCredentialResponse");
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async pickCredential(cipherIds: string[]): Promise<string> {
|
|
||||||
const data: BrowserFido2Message = {
|
const data: BrowserFido2Message = {
|
||||||
type: "PickCredentialRequest",
|
type: "PickCredentialRequest",
|
||||||
cipherIds,
|
cipherIds,
|
||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
|
userVerification,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.send(data);
|
await this.send(data);
|
||||||
const response = await this.receive("PickCredentialResponse");
|
const response = await this.receive("PickCredentialResponse");
|
||||||
|
|
||||||
return response.cipherId;
|
return { cipherId: response.cipherId, userVerified: response.userVerified };
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirmNewCredential({
|
async confirmNewCredential({
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ export interface NewCredentialParams {
|
|||||||
userVerification: boolean;
|
userVerification: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PickCredentialParams {
|
||||||
|
cipherIds: string[];
|
||||||
|
userVerification: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export abstract class Fido2UserInterfaceService {
|
export abstract class Fido2UserInterfaceService {
|
||||||
newSession: (abortController?: AbortController) => Promise<Fido2UserInterfaceSession>;
|
newSession: (abortController?: AbortController) => Promise<Fido2UserInterfaceSession>;
|
||||||
}
|
}
|
||||||
@@ -12,8 +17,10 @@ export abstract class Fido2UserInterfaceSession {
|
|||||||
fallbackRequested = false;
|
fallbackRequested = false;
|
||||||
aborted = false;
|
aborted = false;
|
||||||
|
|
||||||
confirmCredential: (cipherId: string, abortController?: AbortController) => Promise<boolean>;
|
pickCredential: (
|
||||||
pickCredential: (cipherIds: string[], abortController?: AbortController) => Promise<string>;
|
params: PickCredentialParams,
|
||||||
|
abortController?: AbortController
|
||||||
|
) => Promise<{ cipherId: string; userVerified: boolean }>;
|
||||||
confirmNewCredential: (
|
confirmNewCredential: (
|
||||||
params: NewCredentialParams,
|
params: NewCredentialParams,
|
||||||
abortController?: AbortController
|
abortController?: AbortController
|
||||||
|
|||||||
@@ -716,18 +716,30 @@ describe("FidoAuthenticatorService", () => {
|
|||||||
cipherService.getAllDecrypted.mockResolvedValue(ciphers);
|
cipherService.getAllDecrypted.mockResolvedValue(ciphers);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Spec: Prompt the user to select a public key credential source selectedCredential from credentialOptions. */
|
for (const userVerification of [true, false]) {
|
||||||
it("should request confirmation from the user", async () => {
|
/** Spec: Prompt the user to select a public key credential source selectedCredential from credentialOptions. */
|
||||||
userInterfaceSession.pickCredential.mockResolvedValue(ciphers[0].id);
|
it(`should request confirmation from user when user verification is ${userVerification}`, async () => {
|
||||||
|
params.requireUserVerification = userVerification;
|
||||||
|
userInterfaceSession.pickCredential.mockResolvedValue({
|
||||||
|
cipherId: ciphers[0].id,
|
||||||
|
userVerified: userVerification,
|
||||||
|
});
|
||||||
|
|
||||||
await authenticator.getAssertion(params);
|
await authenticator.getAssertion(params);
|
||||||
|
|
||||||
expect(userInterfaceSession.pickCredential).toHaveBeenCalledWith(ciphers.map((c) => c.id));
|
expect(userInterfaceSession.pickCredential).toHaveBeenCalledWith({
|
||||||
});
|
cipherIds: ciphers.map((c) => c.id),
|
||||||
|
userVerification,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Spec: If the user does not consent, return an error code equivalent to "NotAllowedError" and terminate the operation. */
|
/** Spec: If the user does not consent, return an error code equivalent to "NotAllowedError" and terminate the operation. */
|
||||||
it("should throw error", async () => {
|
it("should throw error", async () => {
|
||||||
userInterfaceSession.pickCredential.mockResolvedValue(undefined);
|
userInterfaceSession.pickCredential.mockResolvedValue({
|
||||||
|
cipherId: undefined,
|
||||||
|
userVerified: false,
|
||||||
|
});
|
||||||
|
|
||||||
const result = async () => await authenticator.getAssertion(params);
|
const result = async () => await authenticator.getAssertion(params);
|
||||||
|
|
||||||
@@ -783,7 +795,10 @@ describe("FidoAuthenticatorService", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
cipherService.getAllDecrypted.mockResolvedValue(ciphers);
|
cipherService.getAllDecrypted.mockResolvedValue(ciphers);
|
||||||
userInterfaceSession.pickCredential.mockResolvedValue(ciphers[0].id);
|
userInterfaceSession.pickCredential.mockResolvedValue({
|
||||||
|
cipherId: ciphers[0].id,
|
||||||
|
userVerified: false,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
beforeEach(init);
|
beforeEach(init);
|
||||||
|
|
||||||
|
|||||||
@@ -202,15 +202,22 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
|
|||||||
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed);
|
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedCipherId = await userInterfaceSession.pickCredential(
|
const response = await userInterfaceSession.pickCredential({
|
||||||
cipherOptions.map((cipher) => cipher.id)
|
cipherIds: cipherOptions.map((cipher) => cipher.id),
|
||||||
);
|
userVerification: params.requireUserVerification,
|
||||||
|
});
|
||||||
|
const selectedCipherId = response.cipherId;
|
||||||
|
const userVerified = response.userVerified;
|
||||||
const selectedCipher = cipherOptions.find((c) => c.id === selectedCipherId);
|
const selectedCipher = cipherOptions.find((c) => c.id === selectedCipherId);
|
||||||
|
|
||||||
if (selectedCipher === undefined) {
|
if (selectedCipher === undefined) {
|
||||||
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed);
|
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (params.requireUserVerification && !userVerified) {
|
||||||
|
throw new Fido2AutenticatorError(Fido2AutenticatorErrorCode.NotAllowed);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const selectedFido2Key =
|
const selectedFido2Key =
|
||||||
selectedCipher.type === CipherType.Login
|
selectedCipher.type === CipherType.Login
|
||||||
@@ -235,7 +242,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
|
|||||||
credentialId: Utils.guidToRawFormat(selectedCredentialId),
|
credentialId: Utils.guidToRawFormat(selectedCredentialId),
|
||||||
counter: selectedFido2Key.counter,
|
counter: selectedFido2Key.counter,
|
||||||
userPresence: true,
|
userPresence: true,
|
||||||
userVerification: false,
|
userVerification: userVerified,
|
||||||
});
|
});
|
||||||
|
|
||||||
const signature = await generateSignature({
|
const signature = await generateSignature({
|
||||||
|
|||||||
Reference in New Issue
Block a user