[PM-5302] Refactor Passkey feature enable/disable logic (#7242)
* feat: add missing tests for `isFido2FeatureEnabled` * feat: add user logged in check * chore: rewrite with cartesian product * chore: remove test The test was more complex than the actual function, removing. * feat: add domain exclusion * feat: add origin equal vault case * chore: clean up the old code from `content-secript` * feat: return early to avoid making api calls * fix: prettier linting * fix: incorrect logic inversion --------- Co-authored-by: bnagawiecki <107435978+bnagawiecki@users.noreply.github.com> Co-authored-by: SmithThe4th <gsmith@bitwarden.com>
This commit is contained in:
parent
a682f2a0ef
commit
551d2c2441
|
@ -264,7 +264,7 @@ export default class RuntimeBackground {
|
||||||
this.abortManager.abort(msg.abortedRequestId);
|
this.abortManager.abort(msg.abortedRequestId);
|
||||||
break;
|
break;
|
||||||
case "checkFido2FeatureEnabled":
|
case "checkFido2FeatureEnabled":
|
||||||
return await this.main.fido2ClientService.isFido2FeatureEnabled();
|
return await this.main.fido2ClientService.isFido2FeatureEnabled(msg.hostname, msg.origin);
|
||||||
case "fido2RegisterCredentialRequest":
|
case "fido2RegisterCredentialRequest":
|
||||||
return await this.abortManager.runWithAbortController(
|
return await this.abortManager.runWithAbortController(
|
||||||
msg.requestId,
|
msg.requestId,
|
||||||
|
|
|
@ -9,46 +9,16 @@ import { Messenger } from "./messaging/messenger";
|
||||||
function isFido2FeatureEnabled(): Promise<boolean> {
|
function isFido2FeatureEnabled(): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
chrome.runtime.sendMessage(
|
chrome.runtime.sendMessage(
|
||||||
{ command: "checkFido2FeatureEnabled" },
|
{
|
||||||
|
command: "checkFido2FeatureEnabled",
|
||||||
|
hostname: window.location.hostname,
|
||||||
|
origin: window.location.origin,
|
||||||
|
},
|
||||||
(response: { result?: boolean }) => resolve(response.result),
|
(response: { result?: boolean }) => resolve(response.result),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getFromLocalStorage(keys: string | string[]): Promise<Record<string, any>> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
chrome.storage.local.get(keys, (storage: Record<string, any>) => resolve(storage));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getActiveUserSettings() {
|
|
||||||
// TODO: This is code copied from `notification-bar.tsx`. We should refactor this into a shared function.
|
|
||||||
// Look up the active user id from storage
|
|
||||||
const activeUserIdKey = "activeUserId";
|
|
||||||
let activeUserId: string;
|
|
||||||
|
|
||||||
const activeUserStorageValue = await getFromLocalStorage(activeUserIdKey);
|
|
||||||
if (activeUserStorageValue[activeUserIdKey]) {
|
|
||||||
activeUserId = activeUserStorageValue[activeUserIdKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
const settingsStorage = await getFromLocalStorage(activeUserId);
|
|
||||||
|
|
||||||
// Look up the user's settings from storage
|
|
||||||
return settingsStorage?.[activeUserId]?.settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isDomainExcluded(activeUserSettings: Record<string, any>) {
|
|
||||||
const excludedDomains = activeUserSettings?.neverDomains;
|
|
||||||
return excludedDomains && window.location.hostname in excludedDomains;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hasActiveUser() {
|
|
||||||
const activeUserIdKey = "activeUserId";
|
|
||||||
const activeUserStorageValue = await getFromLocalStorage(activeUserIdKey);
|
|
||||||
return activeUserStorageValue[activeUserIdKey] !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSameOriginWithAncestors() {
|
function isSameOriginWithAncestors() {
|
||||||
try {
|
try {
|
||||||
return window.self === window.top;
|
return window.self === window.top;
|
||||||
|
@ -56,11 +26,6 @@ function isSameOriginWithAncestors() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isLocationBitwardenVault(activeUserSettings: Record<string, any>) {
|
|
||||||
return window.location.origin === activeUserSettings.serverConfig.environment.vault;
|
|
||||||
}
|
|
||||||
|
|
||||||
const messenger = Messenger.forDOMCommunication(window);
|
const messenger = Messenger.forDOMCommunication(window);
|
||||||
|
|
||||||
function injectPageScript() {
|
function injectPageScript() {
|
||||||
|
@ -156,17 +121,7 @@ function initializeFido2ContentScript() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
if (!(await hasActiveUser())) {
|
if (!(await isFido2FeatureEnabled())) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeUserSettings = await getActiveUserSettings();
|
|
||||||
if (
|
|
||||||
activeUserSettings == null ||
|
|
||||||
!(await isFido2FeatureEnabled()) ||
|
|
||||||
(await isDomainExcluded(activeUserSettings)) ||
|
|
||||||
(await isLocationBitwardenVault(activeUserSettings))
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,8 @@ export type UserVerification = "discouraged" | "preferred" | "required";
|
||||||
* and for returning the results of the latter operations to the Web Authentication API's callers.
|
* and for returning the results of the latter operations to the Web Authentication API's callers.
|
||||||
*/
|
*/
|
||||||
export abstract class Fido2ClientService {
|
export abstract class Fido2ClientService {
|
||||||
|
isFido2FeatureEnabled: (hostname: string, origin: string) => Promise<boolean>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allows WebAuthn Relying Party scripts to request the creation of a new public key credential source.
|
* Allows WebAuthn Relying Party scripts to request the creation of a new public key credential source.
|
||||||
* For more information please see: https://www.w3.org/TR/webauthn-3/#sctn-createCredential
|
* For more information please see: https://www.w3.org/TR/webauthn-3/#sctn-createCredential
|
||||||
|
@ -42,8 +44,6 @@ export abstract class Fido2ClientService {
|
||||||
tab: chrome.tabs.Tab,
|
tab: chrome.tabs.Tab,
|
||||||
abortController?: AbortController,
|
abortController?: AbortController,
|
||||||
) => Promise<AssertCredentialResult>;
|
) => Promise<AssertCredentialResult>;
|
||||||
|
|
||||||
isFido2FeatureEnabled: () => Promise<boolean>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { mock, MockProxy } from "jest-mock-extended";
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
|
import { of } from "rxjs";
|
||||||
|
|
||||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||||
|
@ -23,6 +24,8 @@ import { Fido2Utils } from "./fido2-utils";
|
||||||
import { guidToRawFormat } from "./guid-utils";
|
import { guidToRawFormat } from "./guid-utils";
|
||||||
|
|
||||||
const RpId = "bitwarden.com";
|
const RpId = "bitwarden.com";
|
||||||
|
const Origin = "https://bitwarden.com";
|
||||||
|
const VaultUrl = "https://vault.bitwarden.com";
|
||||||
|
|
||||||
describe("FidoAuthenticatorService", () => {
|
describe("FidoAuthenticatorService", () => {
|
||||||
let authenticator!: MockProxy<Fido2AuthenticatorService>;
|
let authenticator!: MockProxy<Fido2AuthenticatorService>;
|
||||||
|
@ -40,7 +43,9 @@ describe("FidoAuthenticatorService", () => {
|
||||||
|
|
||||||
client = new Fido2ClientService(authenticator, configService, authService, stateService);
|
client = new Fido2ClientService(authenticator, configService, authService, stateService);
|
||||||
configService.getFeatureFlag.mockResolvedValue(true);
|
configService.getFeatureFlag.mockResolvedValue(true);
|
||||||
|
configService.serverConfig$ = of({ environment: { vault: VaultUrl } } as any);
|
||||||
stateService.getEnablePasskeys.mockResolvedValue(true);
|
stateService.getEnablePasskeys.mockResolvedValue(true);
|
||||||
|
authService.getAuthStatus.mockResolvedValue(AuthenticationStatus.Unlocked);
|
||||||
tab = { id: 123, windowId: 456 } as chrome.tabs.Tab;
|
tab = { id: 123, windowId: 456 } as chrome.tabs.Tab;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -249,6 +254,15 @@ describe("FidoAuthenticatorService", () => {
|
||||||
const rejects = expect(result).rejects;
|
const rejects = expect(result).rejects;
|
||||||
await rejects.toThrow(FallbackRequestedError);
|
await rejects.toThrow(FallbackRequestedError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should throw FallbackRequestedError if origin equals the bitwarden vault", async () => {
|
||||||
|
const params = createParams({ origin: VaultUrl });
|
||||||
|
|
||||||
|
const result = async () => await client.createCredential(params, tab);
|
||||||
|
|
||||||
|
const rejects = expect(result).rejects;
|
||||||
|
await rejects.toThrow(FallbackRequestedError);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function createParams(params: Partial<CreateCredentialParams> = {}): CreateCredentialParams {
|
function createParams(params: Partial<CreateCredentialParams> = {}): CreateCredentialParams {
|
||||||
|
@ -420,6 +434,15 @@ describe("FidoAuthenticatorService", () => {
|
||||||
const rejects = expect(result).rejects;
|
const rejects = expect(result).rejects;
|
||||||
await rejects.toThrow(FallbackRequestedError);
|
await rejects.toThrow(FallbackRequestedError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should throw FallbackRequestedError if origin equals the bitwarden vault", async () => {
|
||||||
|
const params = createParams({ origin: VaultUrl });
|
||||||
|
|
||||||
|
const result = async () => await client.assertCredential(params, tab);
|
||||||
|
|
||||||
|
const rejects = expect(result).rejects;
|
||||||
|
await rejects.toThrow(FallbackRequestedError);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("assert non-discoverable credential", () => {
|
describe("assert non-discoverable credential", () => {
|
||||||
|
@ -485,7 +508,7 @@ describe("FidoAuthenticatorService", () => {
|
||||||
return {
|
return {
|
||||||
allowedCredentialIds: params.allowedCredentialIds ?? [],
|
allowedCredentialIds: params.allowedCredentialIds ?? [],
|
||||||
challenge: params.challenge ?? Fido2Utils.bufferToString(randomBytes(16)),
|
challenge: params.challenge ?? Fido2Utils.bufferToString(randomBytes(16)),
|
||||||
origin: params.origin ?? "https://bitwarden.com",
|
origin: params.origin ?? Origin,
|
||||||
rpId: params.rpId ?? RpId,
|
rpId: params.rpId ?? RpId,
|
||||||
timeout: params.timeout,
|
timeout: params.timeout,
|
||||||
userVerification: params.userVerification,
|
userVerification: params.userVerification,
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { firstValueFrom } from "rxjs";
|
||||||
import { parse } from "tldts";
|
import { parse } from "tldts";
|
||||||
|
|
||||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||||
|
@ -45,12 +46,31 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
||||||
private logService?: LogService,
|
private logService?: LogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async isFido2FeatureEnabled(): Promise<boolean> {
|
async isFido2FeatureEnabled(hostname: string, origin: string): Promise<boolean> {
|
||||||
|
const userEnabledPasskeys = await this.stateService.getEnablePasskeys();
|
||||||
|
const isUserLoggedIn =
|
||||||
|
(await this.authService.getAuthStatus()) !== AuthenticationStatus.LoggedOut;
|
||||||
|
|
||||||
|
const neverDomains = await this.stateService.getNeverDomains();
|
||||||
|
const isExcludedDomain = neverDomains != null && hostname in neverDomains;
|
||||||
|
|
||||||
|
const serverConfig = await firstValueFrom(this.configService.serverConfig$);
|
||||||
|
const isOriginEqualBitwardenVault = origin === serverConfig.environment?.vault;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!userEnabledPasskeys ||
|
||||||
|
!isUserLoggedIn ||
|
||||||
|
isExcludedDomain ||
|
||||||
|
isOriginEqualBitwardenVault
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const featureFlagEnabled = await this.configService.getFeatureFlag<boolean>(
|
const featureFlagEnabled = await this.configService.getFeatureFlag<boolean>(
|
||||||
FeatureFlag.Fido2VaultCredentials,
|
FeatureFlag.Fido2VaultCredentials,
|
||||||
);
|
);
|
||||||
const userEnabledPasskeys = await this.stateService.getEnablePasskeys();
|
|
||||||
return featureFlagEnabled && userEnabledPasskeys;
|
return featureFlagEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCredential(
|
async createCredential(
|
||||||
|
@ -58,20 +78,17 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
||||||
tab: chrome.tabs.Tab,
|
tab: chrome.tabs.Tab,
|
||||||
abortController = new AbortController(),
|
abortController = new AbortController(),
|
||||||
): Promise<CreateCredentialResult> {
|
): Promise<CreateCredentialResult> {
|
||||||
const enableFido2VaultCredentials = await this.isFido2FeatureEnabled();
|
const parsedOrigin = parse(params.origin, { allowPrivateDomains: true });
|
||||||
|
const enableFido2VaultCredentials = await this.isFido2FeatureEnabled(
|
||||||
|
parsedOrigin.hostname,
|
||||||
|
params.origin,
|
||||||
|
);
|
||||||
|
|
||||||
if (!enableFido2VaultCredentials) {
|
if (!enableFido2VaultCredentials) {
|
||||||
this.logService?.warning(`[Fido2Client] Fido2VaultCredential is not enabled`);
|
this.logService?.warning(`[Fido2Client] Fido2VaultCredential is not enabled`);
|
||||||
throw new FallbackRequestedError();
|
throw new FallbackRequestedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
const authStatus = await this.authService.getAuthStatus();
|
|
||||||
|
|
||||||
if (authStatus === AuthenticationStatus.LoggedOut) {
|
|
||||||
this.logService?.warning(`[Fido2Client] Fido2VaultCredential is not enabled`);
|
|
||||||
throw new FallbackRequestedError();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!params.sameOriginWithAncestors) {
|
if (!params.sameOriginWithAncestors) {
|
||||||
this.logService?.warning(
|
this.logService?.warning(
|
||||||
`[Fido2Client] Invalid 'sameOriginWithAncestors' value: ${params.sameOriginWithAncestors}`,
|
`[Fido2Client] Invalid 'sameOriginWithAncestors' value: ${params.sameOriginWithAncestors}`,
|
||||||
|
@ -87,15 +104,7 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
||||||
throw new TypeError("Invalid 'user.id' length");
|
throw new TypeError("Invalid 'user.id' length");
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedOrigin = parse(params.origin, { allowPrivateDomains: true });
|
|
||||||
params.rp.id = params.rp.id ?? parsedOrigin.hostname;
|
params.rp.id = params.rp.id ?? parsedOrigin.hostname;
|
||||||
|
|
||||||
const neverDomains = await this.stateService.getNeverDomains();
|
|
||||||
if (neverDomains != null && parsedOrigin.hostname in neverDomains) {
|
|
||||||
this.logService?.warning(`[Fido2Client] Excluded domain`);
|
|
||||||
throw new FallbackRequestedError();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedOrigin.hostname == undefined || !params.origin.startsWith("https://")) {
|
if (parsedOrigin.hostname == undefined || !params.origin.startsWith("https://")) {
|
||||||
this.logService?.warning(`[Fido2Client] Invalid https origin: ${params.origin}`);
|
this.logService?.warning(`[Fido2Client] Invalid https origin: ${params.origin}`);
|
||||||
throw new DOMException("'origin' is not a valid https origin", "SecurityError");
|
throw new DOMException("'origin' is not a valid https origin", "SecurityError");
|
||||||
|
@ -210,29 +219,19 @@ export class Fido2ClientService implements Fido2ClientServiceAbstraction {
|
||||||
tab: chrome.tabs.Tab,
|
tab: chrome.tabs.Tab,
|
||||||
abortController = new AbortController(),
|
abortController = new AbortController(),
|
||||||
): Promise<AssertCredentialResult> {
|
): Promise<AssertCredentialResult> {
|
||||||
const enableFido2VaultCredentials = await this.isFido2FeatureEnabled();
|
const parsedOrigin = parse(params.origin, { allowPrivateDomains: true });
|
||||||
|
const enableFido2VaultCredentials = await this.isFido2FeatureEnabled(
|
||||||
|
parsedOrigin.hostname,
|
||||||
|
params.origin,
|
||||||
|
);
|
||||||
|
|
||||||
if (!enableFido2VaultCredentials) {
|
if (!enableFido2VaultCredentials) {
|
||||||
this.logService?.warning(`[Fido2Client] Fido2VaultCredential is not enabled`);
|
this.logService?.warning(`[Fido2Client] Fido2VaultCredential is not enabled`);
|
||||||
throw new FallbackRequestedError();
|
throw new FallbackRequestedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
const authStatus = await this.authService.getAuthStatus();
|
|
||||||
|
|
||||||
if (authStatus === AuthenticationStatus.LoggedOut) {
|
|
||||||
this.logService?.warning(`[Fido2Client] Fido2VaultCredential is not enabled`);
|
|
||||||
throw new FallbackRequestedError();
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedOrigin = parse(params.origin, { allowPrivateDomains: true });
|
|
||||||
params.rpId = params.rpId ?? parsedOrigin.hostname;
|
params.rpId = params.rpId ?? parsedOrigin.hostname;
|
||||||
|
|
||||||
const neverDomains = await this.stateService.getNeverDomains();
|
|
||||||
if (neverDomains != null && parsedOrigin.hostname in neverDomains) {
|
|
||||||
this.logService?.warning(`[Fido2Client] Excluded domain`);
|
|
||||||
throw new FallbackRequestedError();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedOrigin.hostname == undefined || !params.origin.startsWith("https://")) {
|
if (parsedOrigin.hostname == undefined || !params.origin.startsWith("https://")) {
|
||||||
this.logService?.warning(`[Fido2Client] Invalid https origin: ${params.origin}`);
|
this.logService?.warning(`[Fido2Client] Invalid https origin: ${params.origin}`);
|
||||||
throw new DOMException("'origin' is not a valid https origin", "SecurityError");
|
throw new DOMException("'origin' is not a valid https origin", "SecurityError");
|
||||||
|
|
Loading…
Reference in New Issue