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

Merge branch 'main' into task/DEVOPS-1683

This commit is contained in:
Alex Urbina
2023-12-12 18:55:14 -06:00
50 changed files with 2062 additions and 347 deletions

View File

@@ -4,7 +4,7 @@ name: Auto Update Branch
on:
push:
branches:
- 'master'
- 'main'
- 'rc'
paths:
- 'apps/web/**'

View File

@@ -14,7 +14,7 @@ on:
- '!*.txt'
push:
branches:
- 'master'
- 'main'
- 'rc'
- 'hotfix-rc-browser'
paths:
@@ -351,7 +351,7 @@ jobs:
crowdin-push:
name: Crowdin Push
if: github.ref == 'refs/heads/master'
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04
needs:
- build
@@ -380,7 +380,7 @@ jobs:
CROWDIN_PROJECT_ID: "268134"
with:
config: apps/browser/crowdin.yml
crowdin_branch_name: master
crowdin_branch_name: main
upload_sources: true
upload_translations: false
@@ -397,7 +397,7 @@ jobs:
- crowdin-push
steps:
- name: Check if any job failed
if: ${{ (github.ref == 'refs/heads/master') || (github.ref == 'refs/heads/rc') }}
if: ${{ (github.ref == 'refs/heads/main') || (github.ref == 'refs/heads/rc') }}
env:
CLOC_STATUS: ${{ needs.cloc.result }}
SETUP_STATUS: ${{ needs.setup.result }}

View File

@@ -15,7 +15,7 @@ on:
- '.github/workflows/build-cli.yml'
push:
branches:
- 'master'
- 'main'
- 'rc'
- 'hotfix-rc-cli'
paths:
@@ -379,7 +379,7 @@ jobs:
steps:
- name: Check if any job failed
working-directory: ${{ github.workspace }}
if: ${{ (github.ref == 'refs/heads/master') || (github.ref == 'refs/heads/rc') }}
if: ${{ (github.ref == 'refs/heads/main') || (github.ref == 'refs/heads/rc') }}
env:
CLOC_STATUS: ${{ needs.cloc.result }}
SETUP_STATUS: ${{ needs.setup.result }}

View File

@@ -15,7 +15,7 @@ on:
- '.github/workflows/build-desktop.yml'
push:
branches:
- 'master'
- 'main'
- 'rc'
- 'hotfix-rc-desktop'
paths:
@@ -973,7 +973,7 @@ jobs:
APPLE_ID_USERNAME: ${{ secrets.APPLE_ID_USERNAME }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
if: |
(github.ref == 'refs/heads/master'
(github.ref == 'refs/heads/main'
&& needs.setup.outputs.rc_branch_exists == 0
&& needs.setup.outputs.hotfix_branch_exists == 0)
|| (github.ref == 'refs/heads/rc' && needs.setup.outputs.hotfix_branch_exists == 0)
@@ -1154,7 +1154,7 @@ jobs:
crowdin-push:
name: Crowdin Push
if: github.ref == 'refs/heads/master'
if: github.ref == 'refs/heads/main'
needs:
- linux
- windows
@@ -1185,7 +1185,7 @@ jobs:
CROWDIN_PROJECT_ID: "299360"
with:
config: apps/desktop/crowdin.yml
crowdin_branch_name: master
crowdin_branch_name: main
upload_sources: true
upload_translations: false
@@ -1207,7 +1207,7 @@ jobs:
- crowdin-push
steps:
- name: Check if any job failed
if: ${{ (github.ref == 'refs/heads/master') || (github.ref == 'refs/heads/rc') }}
if: ${{ (github.ref == 'refs/heads/main') || (github.ref == 'refs/heads/rc') }}
env:
CLOC_STATUS: ${{ needs.cloc.result }}
ELECTRON_VERIFY_STATUS: ${{ needs.electron-verify.result }}

View File

@@ -15,7 +15,7 @@ on:
- '.github/workflows/build-web.yml'
push:
branches:
- 'master'
- 'main'
- 'rc'
- 'hotfix-rc-web'
paths:
@@ -170,7 +170,7 @@ jobs:
- name: Check Branch to Publish
env:
PUBLISH_BRANCHES: "master,rc,hotfix-rc-web"
PUBLISH_BRANCHES: "main,rc,hotfix-rc-web"
id: publish-branch-check
run: |
IFS="," read -a publish_branches <<< $PUBLISH_BRANCHES
@@ -218,7 +218,7 @@ jobs:
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g")
fi
if [[ "$IMAGE_TAG" == "master" ]]; then
if [[ "$IMAGE_TAG" == "main" ]]; then
IMAGE_TAG=dev
fi
@@ -259,7 +259,7 @@ jobs:
crowdin-push:
name: Crowdin Push
if: github.ref == 'refs/heads/master'
if: github.ref == 'refs/heads/main'
needs: build-artifacts
runs-on: ubuntu-22.04
steps:
@@ -286,7 +286,7 @@ jobs:
CROWDIN_PROJECT_ID: "308189"
with:
config: apps/web/crowdin.yml
crowdin_branch_name: master
crowdin_branch_name: main
upload_sources: true
upload_translations: false
@@ -303,7 +303,7 @@ jobs:
- crowdin-push
steps:
- name: Check if any job failed
if: ${{ (github.ref == 'refs/heads/master') || (github.ref == 'refs/heads/rc') }}
if: ${{ (github.ref == 'refs/heads/main') || (github.ref == 'refs/heads/rc') }}
env:
CLOC_STATUS: ${{ needs.cloc.result }}
SETUP_STATUS: ${{ needs.setup.result }}

View File

@@ -45,7 +45,7 @@ jobs:
CROWDIN_PROJECT_ID: ${{ matrix.crowdin_project_id }}
with:
config: crowdin.yml
crowdin_branch_name: master
crowdin_branch_name: main
upload_sources: false
upload_translations: false
download_translations: true

View File

@@ -1,5 +1,5 @@
# Runs creation of Pull Requests
# If the PR destination branch is master, add a needs-qa label unless created by renovate[bot]
# If the PR destination branch is main, add a needs-qa label unless created by renovate[bot]
---
name: Label Issue Pull Request
@@ -10,7 +10,7 @@ on:
paths-ignore:
- .github/workflows/** # We don't need QA on workflow changes
branches:
- 'master' # We only want to check when PRs target master
- 'main' # We only want to check when PRs target main
jobs:
add-needs-qa-label:

View File

@@ -114,13 +114,13 @@ jobs:
dist-firefox-*.zip,
dist-edge-*.zip'
- name: Dry Run - Download latest master build artifacts
- name: Dry Run - Download latest build artifacts
if: ${{ github.event.inputs.release_type == 'Dry Run' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
workflow: build-browser.yml
workflow_conclusion: success
branch: master
branch: main
artifacts: 'browser-source-*.zip,
dist-chrome-*.zip,
dist-opera-*.zip,

View File

@@ -92,7 +92,7 @@ jobs:
workflow: build-cli.yml
path: apps/cli
workflow_conclusion: success
branch: master
branch: main
- name: Create release
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
@@ -175,7 +175,7 @@ jobs:
workflow: build-cli.yml
path: apps/cli
workflow_conclusion: success
branch: master
branch: main
artifacts: bw_${{ env._PKG_VERSION }}_amd64.snap
- name: Publish Snap & logout
@@ -235,7 +235,7 @@ jobs:
workflow: build-cli.yml
path: apps/cli/dist
workflow_conclusion: success
branch: master
branch: main
artifacts: bitwarden-cli.${{ env._PKG_VERSION }}.nupkg
- name: Push to Chocolatey
@@ -285,7 +285,7 @@ jobs:
workflow: build-cli.yml
path: apps/cli/build
workflow_conclusion: success
branch: master
branch: main
artifacts: bitwarden-cli-${{ env._PKG_VERSION }}-npm-build.zip
- name: Setup NPM

View File

@@ -28,9 +28,9 @@ jobs:
- name: Branch check
run: |
if [[ "$GITHUB_REF" != "refs/heads/master" ]] && [[ "$GITHUB_REF" != "refs/heads/rc" ]] && [[ "$GITHUB_REF" != "refs/heads/hotfix-rc" ]]; then
if [[ "$GITHUB_REF" != "refs/heads/main" ]] && [[ "$GITHUB_REF" != "refs/heads/rc" ]] && [[ "$GITHUB_REF" != "refs/heads/hotfix-rc" ]]; then
echo "==================================="
echo "[!] Can only release from the 'master', 'rc' or 'hotfix-rc' branches"
echo "[!] Can only release from the 'main', 'rc' or 'hotfix-rc' branches"
echo "==================================="
exit 1
fi
@@ -661,13 +661,13 @@ jobs:
branch: rc
path: ${{ github.workspace }}/browser-build-artifacts
- name: Download artifact from master
- name: Download artifacts from main
if: ${{ github.ref != 'refs/heads/rc' && github.ref != 'refs/heads/hotfix-rc' }}
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2.27.0
with:
workflow: build-browser.yml
workflow_conclusion: success
branch: master
branch: main
path: ${{ github.workspace }}/browser-build-artifacts
- name: Unzip Safari artifact
@@ -859,13 +859,13 @@ jobs:
branch: rc
path: ${{ github.workspace }}/browser-build-artifacts
- name: Download artifact from master
- name: Download artifact from main
if: ${{ github.ref != 'refs/heads/rc' && github.ref != 'refs/heads/hotfix-rc' }}
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2.27.0
with:
workflow: build-browser.yml
workflow_conclusion: success
branch: master
branch: main
path: ${{ github.workspace }}/browser-build-artifacts
- name: Unzip Safari artifact

View File

@@ -136,7 +136,7 @@ jobs:
with:
workflow: build-desktop.yml
workflow_conclusion: success
branch: master
branch: main
path: apps/desktop/artifacts
- name: Rename .pkg to .pkg.archive
@@ -291,7 +291,7 @@ jobs:
with:
workflow: build-desktop.yml
workflow_conclusion: success
branch: master
branch: main
artifacts: bitwarden_${{ env._PKG_VERSION }}_amd64.snap
path: apps/desktop/dist
@@ -359,7 +359,7 @@ jobs:
with:
workflow: build-desktop.yml
workflow_conclusion: success
branch: master
branch: main
artifacts: bitwarden.${{ env._PKG_VERSION }}.nupkg
path: apps/desktop/dist

View File

@@ -159,7 +159,7 @@ jobs:
workflow: build-web.yml
path: assets
workflow_conclusion: success
branch: master
branch: main
artifacts: web-*-cloud-COMMERCIAL.zip
- name: Unzip build asset
@@ -196,12 +196,12 @@ jobs:
gh pr create --title "Deploy v${_RELEASE_VERSION} to GitHub Pages" \
--draft \
--body "Deploying v${_RELEASE_VERSION}" \
--base master \
--base main \
--head "${_BRANCH}"
else
gh pr create --title "Deploy v${_RELEASE_VERSION} to GitHub Pages" \
--body "Deploying v${_RELEASE_VERSION}" \
--base master \
--base main \
--head "${_BRANCH}"
fi
@@ -243,7 +243,7 @@ jobs:
workflow: build-web.yml
path: apps/web/artifacts
workflow_conclusion: success
branch: master
branch: main
artifacts: "web-*-selfhosted-COMMERCIAL.zip,
web-*-selfhosted-open-source.zip"

View File

@@ -27,4 +27,4 @@ jobs:
If youre still working on this, please respond here after youve made the changes weve requested and our team will re-open it for further review.
Please make sure to resolve any conflicts with the master branch before requesting another review.
Please make sure to resolve any conflicts with the main branch before requesting another review.

View File

@@ -286,7 +286,7 @@ jobs:
TITLE: "Bump ${{ steps.create-branch.outputs.client }} version to ${{ inputs.version_number }}"
run: |
PR_URL=$(gh pr create --title "$TITLE" \
--base "master" \
--base "main" \
--head "$PR_BRANCH" \
--label "version update" \
--label "automated pr" \

View File

@@ -268,6 +268,9 @@
"length": {
"message": "Length"
},
"passwordMinLength": {
"message": "Minimum password length"
},
"uppercase": {
"message": "Uppercase (A-Z)"
},

View File

@@ -40,7 +40,7 @@
<div class="tw-grid tw-gap-2">
<button
type="button"
class="tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-bg-background tw-p-3 hover:tw-bg-background-alt disabled:tw-cursor-not-allowed disabled:tw-border-text-muted/60 disabled:!tw-text-muted/60"
class="account-switcher-row tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-p-3 disabled:tw-cursor-not-allowed disabled:tw-border-text-muted/60 disabled:!tw-text-muted/60"
(click)="lock()"
[disabled]="currentAccount.status === lockedStatus || !activeUserCanLock"
[title]="!activeUserCanLock ? ('unlockMethodNeeded' | i18n) : ''"
@@ -50,7 +50,7 @@
</button>
<button
type="button"
class="tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-bg-background tw-p-3 hover:tw-bg-background-alt"
class="account-switcher-row tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-p-3"
(click)="logOut()"
>
<i class="bwi bwi-sign-out tw-text-2xl" aria-hidden="true"></i>
@@ -58,7 +58,7 @@
</button>
<button
type="button"
class="tw-mt-2 tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-bg-background tw-p-3 hover:tw-bg-background-alt"
class="account-switcher-row tw-mt-2 tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-p-3"
(click)="lockAll()"
>
<i class="bwi bwi-lock tw-text-2xl" aria-hidden="true"></i>

View File

@@ -1,7 +1,7 @@
<button
*ngIf="account.id !== specialAccountAddId"
type="button"
class="tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-border-none tw-bg-background tw-p-3 hover:tw-bg-background-alt"
class="account-switcher-row tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-border-none tw-p-3"
(click)="selectAccount(account.id)"
>
<div class="tw-flex-shrink-0">
@@ -18,32 +18,35 @@
<span class="tw-sr-only" *ngIf="status.text !== 'active'">
{{ "switchToAccount" | i18n }}
</span>
<div class="tw-max-w-64 tw-truncate tw-text-main">
<div class="tw-max-w-64 tw-truncate">
{{ account.email }}
</div>
<div class="tw-max-w-64 tw-truncate tw-text-sm tw-text-muted">
<div class="account-switcher-row-details tw-max-w-64 tw-truncate tw-text-sm">
<span class="tw-sr-only">{{ "hostedAt" | i18n }}</span>
{{ account.server }}
</div>
<div class="tw-text-sm tw-italic tw-text-muted" [attr.aria-hidden]="status.text === 'active'">
<div
class="account-switcher-row-details tw-text-sm tw-italic"
[attr.aria-hidden]="status.text === 'active'"
>
<span class="tw-sr-only">(</span>
{{ status.text }}
<span class="tw-sr-only">)</span>
</div>
</div>
<div class="tw-ml-auto tw-flex-shrink-0">
<i class="bwi tw-text-2xl tw-text-main" [ngClass]="status.icon" aria-hidden="true"></i>
<i class="bwi tw-text-2xl" [ngClass]="status.icon" aria-hidden="true"></i>
</div>
</button>
<button
*ngIf="account.id === specialAccountAddId"
type="button"
class="tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-border-none tw-bg-background tw-p-3 hover:tw-bg-background-alt"
class="account-switcher-row tw-flex tw-w-full tw-items-center tw-gap-3 tw-rounded-md tw-border-none tw-p-3"
(click)="selectAccount(account.id)"
>
<i class="bwi bwi-plus tw-text-2xl tw-text-main" aria-hidden="true"></i>
<div class="tw-text-main">
<i class="bwi bwi-plus tw-text-2xl" aria-hidden="true"></i>
<div>
{{ account.name }}
</div>
</button>

View File

@@ -156,7 +156,9 @@ import { BrowserSendService } from "../services/browser-send.service";
import { BrowserSettingsService } from "../services/browser-settings.service";
import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service";
import { BrowserFido2UserInterfaceService } from "../vault/fido2/browser-fido2-user-interface.service";
import { Fido2Service as Fido2ServiceAbstraction } from "../vault/services/abstractions/fido2.service";
import { BrowserFolderService } from "../vault/services/browser-folder.service";
import Fido2Service from "../vault/services/fido2.service";
import { VaultFilterService } from "../vault/services/vault-filter.service";
import CommandsBackground from "./commands.background";
@@ -232,6 +234,7 @@ export default class MainBackground {
authRequestCryptoService: AuthRequestCryptoServiceAbstraction;
accountService: AccountServiceAbstraction;
globalStateProvider: GlobalStateProvider;
fido2Service: Fido2ServiceAbstraction;
// Passed to the popup for Safari to workaround issues with theming, downloading, etc.
backgroundWindow = window;
@@ -597,6 +600,7 @@ export default class MainBackground {
this.messagingService,
);
this.fido2Service = new Fido2Service();
this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService);
this.fido2AuthenticatorService = new Fido2AuthenticatorService(
this.cipherService,
@@ -645,6 +649,7 @@ export default class MainBackground {
this.messagingService,
this.logService,
this.configService,
this.fido2Service,
);
this.nativeMessagingBackground = new NativeMessagingBackground(
this.cryptoService,
@@ -778,6 +783,8 @@ export default class MainBackground {
await this.idleBackground.init();
await this.webRequestBackground.init();
await this.fido2Service.init();
if (this.platformUtilsService.isFirefox() && !this.isPrivateMode) {
// Set Private Mode windows to the default icon - they do not share state with the background page
const privateWindows = await BrowserApi.getPrivateModeWindows();

View File

@@ -20,6 +20,7 @@ import { BrowserStateService } from "../platform/services/abstractions/browser-s
import { BrowserEnvironmentService } from "../platform/services/browser-environment.service";
import BrowserPlatformUtilsService from "../platform/services/browser-platform-utils.service";
import { AbortManager } from "../vault/background/abort-manager";
import { Fido2Service } from "../vault/services/abstractions/fido2.service";
import MainBackground from "./main.background";
@@ -42,6 +43,7 @@ export default class RuntimeBackground {
private messagingService: MessagingService,
private logService: LogService,
private configService: ConfigServiceAbstraction,
private fido2Service: Fido2Service,
) {
// onInstalled listener must be wired up before anything else, so we do it in the ctor
chrome.runtime.onInstalled.addListener((details: any) => {
@@ -257,6 +259,9 @@ export default class RuntimeBackground {
case "getClickedElementResponse":
this.platformUtilsService.copyToClipboard(msg.identifier, { window: window });
break;
case "triggerFido2ContentScriptInjection":
await this.fido2Service.injectFido2ContentScripts(sender);
break;
case "fido2AbortRequest":
this.abortManager.abort(msg.abortedRequestId);
break;

View File

@@ -17,7 +17,10 @@
"content_scripts": [
{
"all_frames": true,
"js": ["content/trigger-autofill-script-injection.js", "content/fido2/content-script.js"],
"js": [
"content/trigger-autofill-script-injection.js",
"content/fido2/trigger-fido2-content-script-injection.js"
],
"matches": ["http://*/*", "https://*/*", "file:///*"],
"run_at": "document_start"
},

View File

@@ -341,7 +341,8 @@
}
}
.img-right {
.img-right,
.txt-right {
float: right;
margin-left: 10px;
}
@@ -767,3 +768,24 @@ form {
}
}
}
.account-switcher-row {
@include themify($themes) {
color: themed("textColor");
background-color: themed("boxBackgroundColor");
}
&:hover,
&:focus,
&.active {
@include themify($themes) {
background-color: themed("listItemBackgroundHoverColor");
}
}
&-details {
@include themify($themes) {
color: themed("mutedColor");
}
}
}

View File

@@ -176,7 +176,7 @@
<input
id="length"
type="number"
min="5"
[min]="passwordOptions.minLength"
max="128"
[(ngModel)]="passwordOptions.length"
(change)="savePasswordOptions()"
@@ -184,7 +184,7 @@
<input
id="lengthRange"
type="range"
min="5"
[min]="passwordOptions.minLength"
max="128"
step="1"
[(ngModel)]="passwordOptions.length"
@@ -194,6 +194,18 @@
tabindex="-1"
/>
</div>
<div class="box-content-row" appBoxRow>
<span>{{ "passwordMinLength" | i18n }}</span>
<span
class="sr-only"
attr.aria-label="{{ 'passwordMinLength' | i18n }}"
role="status"
aria-live="polite"
>
{{ passwordOptionsMinLengthForReader$ | async }}
</span>
<span class="txt-right">{{ passwordOptions.minLength }}</span>
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="uppercase">A-Z</label>
<input
@@ -221,10 +233,10 @@
<input
id="numbers"
type="checkbox"
(change)="savePasswordOptions()"
attr.aria-label="{{ 'numbers' | i18n }}"
[disabled]="enforcedPasswordPolicyOptions.useNumbers"
[(ngModel)]="passwordOptions.number"
[ngModel]="passwordOptions.number"
(ngModelChange)="setPasswordOptionsNumber($event)"
/>
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>
@@ -232,10 +244,10 @@
<input
id="special"
type="checkbox"
(change)="savePasswordOptions()"
attr.aria-label="{{ 'specialCharacters' | i18n }}"
[disabled]="enforcedPasswordPolicyOptions.useSpecial"
[(ngModel)]="passwordOptions.special"
[ngModel]="passwordOptions.special"
(ngModelChange)="setPasswordOptionsSpecial($event)"
/>
</div>
</div>
@@ -249,8 +261,8 @@
type="number"
min="0"
max="9"
(change)="savePasswordOptions()"
[(ngModel)]="passwordOptions.minNumber"
(input)="onPasswordOptionsMinNumberInput($event)"
/>
</div>
<div class="box-content-row box-content-row-input" appBoxRow>
@@ -260,8 +272,8 @@
type="number"
min="0"
max="9"
(change)="savePasswordOptions()"
[(ngModel)]="passwordOptions.minSpecial"
(input)="onPasswordOptionsMinSpecialInput($event)"
/>
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>

View File

@@ -61,12 +61,28 @@ async function isLocationBitwardenVault(activeUserSettings: Record<string, any>)
return window.location.origin === activeUserSettings.serverConfig.environment.vault;
}
function initializeFido2ContentScript() {
const messenger = Messenger.forDOMCommunication(window);
function injectPageScript() {
// Locate an existing page-script on the page
const existingPageScript = document.getElementById("bw-fido2-page-script");
// Inject the page-script if it doesn't exist
if (!existingPageScript) {
const s = document.createElement("script");
s.src = chrome.runtime.getURL("content/fido2/page-script.js");
s.id = "bw-fido2-page-script";
(document.head || document.documentElement).appendChild(s);
const messenger = Messenger.forDOMCommunication(window);
return;
}
// If the page-script already exists, send a reconnect message to the page-script
messenger.sendReconnectCommand();
}
function initializeFido2ContentScript() {
injectPageScript();
messenger.handler = async (message, abortController) => {
const requestId = Date.now().toString();
@@ -78,7 +94,7 @@ function initializeFido2ContentScript() {
abortController.signal.addEventListener("abort", abortHandler);
if (message.type === MessageType.CredentialCreationRequest) {
return new Promise((resolve, reject) => {
return new Promise<Message | undefined>((resolve, reject) => {
const data: CreateCredentialParams = {
...message.data,
origin: window.location.origin,
@@ -92,7 +108,7 @@ function initializeFido2ContentScript() {
requestId: requestId,
},
(response) => {
if (response.error !== undefined) {
if (response && response.error !== undefined) {
return reject(response.error);
}
@@ -106,7 +122,7 @@ function initializeFido2ContentScript() {
}
if (message.type === MessageType.CredentialGetRequest) {
return new Promise((resolve, reject) => {
return new Promise<Message | undefined>((resolve, reject) => {
const data: AssertCredentialParams = {
...message.data,
origin: window.location.origin,
@@ -120,7 +136,7 @@ function initializeFido2ContentScript() {
requestId: requestId,
},
(response) => {
if (response.error !== undefined) {
if (response && response.error !== undefined) {
return reject(response.error);
}
@@ -155,6 +171,12 @@ async function run() {
}
initializeFido2ContentScript();
const port = chrome.runtime.connect({ name: "fido2ContentScriptReady" });
port.onDisconnect.addListener(() => {
// Cleanup the messenger and remove the event listener
messenger.destroy();
});
}
run();

View File

@@ -11,6 +11,8 @@ export enum MessageType {
CredentialGetRequest,
CredentialGetResponse,
AbortRequest,
DisconnectRequest,
ReconnectRequest,
AbortResponse,
ErrorResponse,
}
@@ -60,6 +62,14 @@ export type AbortRequest = {
abortedRequestId: string;
};
export type DisconnectRequest = {
type: MessageType.DisconnectRequest;
};
export type ReconnectRequest = {
type: MessageType.ReconnectRequest;
};
export type ErrorResponse = {
type: MessageType.ErrorResponse;
error: string;
@@ -76,5 +86,7 @@ export type Message =
| CredentialGetRequest
| CredentialGetResponse
| AbortRequest
| DisconnectRequest
| ReconnectRequest
| AbortResponse
| ErrorResponse;

View File

@@ -12,6 +12,12 @@ describe("Messenger", () => {
beforeEach(() => {
// jest does not support MessageChannel
window.MessageChannel = MockMessageChannel as any;
Object.defineProperty(window, "location", {
value: {
origin: "https://bitwarden.com",
},
writable: true,
});
const channelPair = new TestChannelPair();
messengerA = new Messenger(channelPair.channelA);
@@ -27,7 +33,7 @@ describe("Messenger", () => {
const request = createRequest();
messengerA.request(request);
const received = handlerB.recieve();
const received = handlerB.receive();
expect(received.length).toBe(1);
expect(received[0].message).toMatchObject(request);
@@ -37,7 +43,7 @@ describe("Messenger", () => {
const request = createRequest();
const response = createResponse();
const requestPromise = messengerA.request(request);
const received = handlerB.recieve();
const received = handlerB.receive();
received[0].respond(response);
const returned = await requestPromise;
@@ -49,7 +55,7 @@ describe("Messenger", () => {
const request = createRequest();
const error = new Error("Test error");
const requestPromise = messengerA.request(request);
const received = handlerB.recieve();
const received = handlerB.receive();
received[0].reject(error);
@@ -61,10 +67,60 @@ describe("Messenger", () => {
messengerA.request(createRequest(), abortController);
abortController.abort();
const received = handlerB.recieve();
const received = handlerB.receive();
expect(received[0].abortController.signal.aborted).toBe(true);
});
describe("destroy", () => {
beforeEach(() => {
/**
* In Jest's jsdom environment, there is an issue where event listeners are not
* triggered upon dispatching an event. This is a workaround to mock the EventTarget
*/
window.EventTarget = MockEventTarget as any;
});
it("should remove the message event listener", async () => {
const channelPair = new TestChannelPair();
const addEventListenerSpy = jest.spyOn(channelPair.channelA, "addEventListener");
const removeEventListenerSpy = jest.spyOn(channelPair.channelA, "removeEventListener");
messengerA = new Messenger(channelPair.channelA);
jest
.spyOn(messengerA as any, "sendDisconnectCommand")
.mockImplementation(() => Promise.resolve());
expect(addEventListenerSpy).toHaveBeenCalled();
await messengerA.destroy();
expect(removeEventListenerSpy).toHaveBeenCalled();
});
it("should dispatch the destroy event on messenger destruction", async () => {
const request = createRequest();
messengerA.request(request);
const dispatchEventSpy = jest.spyOn((messengerA as any).onDestroy, "dispatchEvent");
messengerA.destroy();
expect(dispatchEventSpy).toHaveBeenCalledWith(expect.any(Event));
});
it("should trigger onDestroyListener when the destroy event is dispatched", async () => {
const request = createRequest();
messengerA.request(request);
const onDestroyListener = jest.fn();
(messengerA as any).onDestroy.addEventListener("destroy", onDestroyListener);
messengerA.destroy();
expect(onDestroyListener).toHaveBeenCalled();
const eventArg = onDestroyListener.mock.calls[0][0];
expect(eventArg).toBeInstanceOf(Event);
expect(eventArg.type).toBe("destroy");
});
});
});
type TestMessage = MessageWithMetadata & { testId: string };
@@ -86,11 +142,13 @@ class TestChannelPair {
this.channelA = {
addEventListener: (listener) => (broadcastChannel.port1.onmessage = listener),
removeEventListener: () => (broadcastChannel.port1.onmessage = null),
postMessage: (message, port) => broadcastChannel.port1.postMessage(message, port),
};
this.channelB = {
addEventListener: (listener) => (broadcastChannel.port2.onmessage = listener),
removeEventListener: () => (broadcastChannel.port1.onmessage = null),
postMessage: (message, port) => broadcastChannel.port2.postMessage(message, port),
};
}
@@ -102,7 +160,7 @@ class TestMessageHandler {
abortController?: AbortController,
) => Promise<Message | undefined>;
private recievedMessages: {
private receivedMessages: {
message: TestMessage;
respond: (response: TestMessage) => void;
reject: (error: Error) => void;
@@ -112,7 +170,7 @@ class TestMessageHandler {
constructor() {
this.handler = (message, abortController) =>
new Promise((resolve, reject) => {
this.recievedMessages.push({
this.receivedMessages.push({
message,
abortController,
respond: (response) => resolve(response),
@@ -121,9 +179,9 @@ class TestMessageHandler {
});
}
recieve() {
const received = this.recievedMessages;
this.recievedMessages = [];
receive() {
const received = this.receivedMessages;
this.receivedMessages = [];
return received;
}
}
@@ -144,7 +202,11 @@ class MockMessagePort<T> {
postMessage(message: T, port?: MessagePort) {
this.remotePort.onmessage(
new MessageEvent("message", { data: message, ports: port ? [port] : [] }),
new MessageEvent("message", {
data: message,
ports: port ? [port] : [],
origin: "https://bitwarden.com",
}),
);
}
@@ -152,3 +214,20 @@ class MockMessagePort<T> {
// Do nothing
}
}
class MockEventTarget {
listeners: Record<string, EventListener[]> = {};
addEventListener(type: string, callback: EventListener) {
this.listeners[type] = this.listeners[type] || [];
this.listeners[type].push(callback);
}
dispatchEvent(event: Event) {
(this.listeners[event.type] || []).forEach((callback) => callback(event));
}
removeEventListener(type: string, callback: EventListener) {
this.listeners[type] = (this.listeners[type] || []).filter((listener) => listener !== callback);
}
}

View File

@@ -1,3 +1,5 @@
import { FallbackRequestedError } from "@bitwarden/common/vault/abstractions/fido2/fido2-client.service.abstraction";
import { Message, MessageType } from "./message";
const SENDER = "bitwarden-webauthn";
@@ -6,15 +8,16 @@ type PostMessageFunction = (message: MessageWithMetadata, remotePort: MessagePor
export type Channel = {
addEventListener: (listener: (message: MessageEvent<MessageWithMetadata>) => void) => void;
removeEventListener: (listener: (message: MessageEvent<MessageWithMetadata>) => void) => void;
postMessage: PostMessageFunction;
};
export type Metadata = { SENDER: typeof SENDER };
export type Metadata = { SENDER: typeof SENDER; senderId: string };
export type MessageWithMetadata = Message & Metadata;
type Handler = (
message: MessageWithMetadata,
abortController?: AbortController,
) => Promise<Message | undefined>;
) => void | Promise<Message | undefined>;
/**
* A class that handles communication between the page and content script. It converts
@@ -22,6 +25,9 @@ type Handler = (
* handling aborts and exceptions across separate execution contexts.
*/
export class Messenger {
private messageEventListener: (event: MessageEvent<MessageWithMetadata>) => void | null = null;
private onDestroy = new EventTarget();
/**
* Creates a messenger that uses the browser's `window.postMessage` API to initiate
* requests in the content script. Every request will then create it's own
@@ -35,14 +41,8 @@ export class Messenger {
return new Messenger({
postMessage: (message, port) => window.postMessage(message, windowOrigin, [port]),
addEventListener: (listener) =>
window.addEventListener("message", (event: MessageEvent<unknown>) => {
if (event.origin !== windowOrigin) {
return;
}
listener(event as MessageEvent<MessageWithMetadata>);
}),
addEventListener: (listener) => window.addEventListener("message", listener),
removeEventListener: (listener) => window.removeEventListener("message", listener),
});
}
@@ -53,38 +53,11 @@ export class Messenger {
*/
handler?: Handler;
private messengerId = this.generateUniqueId();
constructor(private broadcastChannel: Channel) {
this.broadcastChannel.addEventListener(async (event) => {
if (this.handler === undefined) {
return;
}
const message = event.data;
const port = event.ports?.[0];
if (message?.SENDER !== SENDER || message == null || port == null) {
return;
}
const abortController = new AbortController();
port.onmessage = (event: MessageEvent<MessageWithMetadata>) => {
if (event.data.type === MessageType.AbortRequest) {
abortController.abort();
}
};
try {
const handlerResponse = await this.handler(message, abortController);
port.postMessage({ ...handlerResponse, SENDER });
} catch (error) {
port.postMessage({
SENDER,
type: MessageType.ErrorResponse,
error: JSON.stringify(error, Object.getOwnPropertyNames(error)),
});
} finally {
port.close();
}
});
this.messageEventListener = this.createMessageEventListener();
this.broadcastChannel.addEventListener(this.messageEventListener);
}
/**
@@ -111,7 +84,10 @@ export class Messenger {
});
abortController?.signal.addEventListener("abort", abortListener);
this.broadcastChannel.postMessage({ ...request, SENDER }, remotePort);
this.broadcastChannel.postMessage(
{ ...request, SENDER, senderId: this.messengerId },
remotePort,
);
const response = await promise;
abortController?.signal.removeEventListener("abort", abortListener);
@@ -127,4 +103,79 @@ export class Messenger {
localPort.close();
}
}
private createMessageEventListener() {
return async (event: MessageEvent<MessageWithMetadata>) => {
const windowOrigin = window.location.origin;
if (event.origin !== windowOrigin || !this.handler) {
return;
}
const message = event.data;
const port = event.ports?.[0];
if (
message?.SENDER !== SENDER ||
message.senderId == this.messengerId ||
message == null ||
port == null
) {
return;
}
const abortController = new AbortController();
port.onmessage = (event: MessageEvent<MessageWithMetadata>) => {
if (event.data.type === MessageType.AbortRequest) {
abortController.abort();
}
};
let onDestroyListener;
const destroyPromise: Promise<never> = new Promise((_, reject) => {
onDestroyListener = () => reject(new FallbackRequestedError());
this.onDestroy.addEventListener("destroy", onDestroyListener);
});
try {
const handlerResponse = await Promise.race([
this.handler(message, abortController),
destroyPromise,
]);
port.postMessage({ ...handlerResponse, SENDER });
} catch (error) {
port.postMessage({
SENDER,
type: MessageType.ErrorResponse,
error: JSON.stringify(error, Object.getOwnPropertyNames(error)),
});
} finally {
this.onDestroy.removeEventListener("destroy", onDestroyListener);
port.close();
}
};
}
/**
* Cleans up the messenger by removing the message event listener
*/
async destroy() {
this.onDestroy.dispatchEvent(new Event("destroy"));
if (this.messageEventListener) {
await this.sendDisconnectCommand();
this.broadcastChannel.removeEventListener(this.messageEventListener);
this.messageEventListener = null;
}
}
async sendReconnectCommand() {
await this.request({ type: MessageType.ReconnectRequest });
}
private async sendDisconnectCommand() {
await this.request({ type: MessageType.DisconnectRequest });
}
private generateUniqueId() {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
}
}

View File

@@ -53,10 +53,21 @@ const browserCredentials = {
};
const messenger = ((window as any).messenger = Messenger.forDOMCommunication(window));
navigator.credentials.create = async (
navigator.credentials.create = createWebAuthnCredential;
navigator.credentials.get = getWebAuthnCredential;
/**
* Creates a new webauthn credential.
*
* @param options Options for creating new credentials.
* @param abortController Abort controller to abort the request if needed.
* @returns Promise that resolves to the new credential object.
*/
async function createWebAuthnCredential(
options?: CredentialCreationOptions,
abortController?: AbortController,
): Promise<Credential> => {
): Promise<Credential> {
if (!isWebauthnCall(options)) {
return await browserCredentials.create(options);
}
@@ -88,12 +99,19 @@ navigator.credentials.create = async (
throw error;
}
};
}
navigator.credentials.get = async (
/**
* Retrieves a webauthn credential.
*
* @param options Options for creating new credentials.
* @param abortController Abort controller to abort the request if needed.
* @returns Promise that resolves to the new credential object.
*/
async function getWebAuthnCredential(
options?: CredentialRequestOptions,
abortController?: AbortController,
): Promise<Credential> => {
): Promise<Credential> {
if (!isWebauthnCall(options)) {
return await browserCredentials.get(options);
}
@@ -126,7 +144,7 @@ navigator.credentials.get = async (
throw error;
}
};
}
function isWebauthnCall(options?: CredentialCreationOptions | CredentialRequestOptions) {
return options && "publicKey" in options;
@@ -174,3 +192,23 @@ async function waitForFocus(fallbackWait = 500, timeout = 5 * 60 * 1000) {
window.clearTimeout(timeoutId);
}
}
/**
* Sets up a listener to handle cleanup or reconnection when the extension's
* context changes due to being reloaded or unloaded.
*/
messenger.handler = (message, abortController) => {
const type = message.type;
// Handle cleanup for disconnect request
if (type === MessageType.DisconnectRequest && browserNativeWebauthnSupport) {
navigator.credentials.create = browserCredentials.create;
navigator.credentials.get = browserCredentials.get;
}
// Handle reinitialization for reconnect request
if (type === MessageType.ReconnectRequest && browserNativeWebauthnSupport) {
navigator.credentials.create = createWebAuthnCredential;
navigator.credentials.get = getWebAuthnCredential;
}
};

View File

@@ -0,0 +1,16 @@
describe("TriggerFido2ContentScriptInjection", () => {
afterEach(() => {
jest.resetModules();
jest.clearAllMocks();
});
describe("init", () => {
it("sends a message to the extension background", () => {
require("../content/trigger-fido2-content-script-injection");
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
command: "triggerFido2ContentScriptInjection",
});
});
});
});

View File

@@ -0,0 +1,3 @@
(function () {
chrome.runtime.sendMessage({ command: "triggerFido2ContentScriptInjection" });
})();

View File

@@ -0,0 +1,4 @@
export abstract class Fido2Service {
init: () => Promise<void>;
injectFido2ContentScripts: (sender: chrome.runtime.MessageSender) => Promise<void>;
}

View File

@@ -0,0 +1,35 @@
import { BrowserApi } from "../../platform/browser/browser-api";
import Fido2Service from "./fido2.service";
describe("Fido2Service", () => {
let fido2Service: Fido2Service;
let tabMock: chrome.tabs.Tab;
let sender: chrome.runtime.MessageSender;
beforeEach(() => {
fido2Service = new Fido2Service();
tabMock = { id: 123, url: "https://bitwarden.com" } as chrome.tabs.Tab;
sender = { tab: tabMock };
jest.spyOn(BrowserApi, "executeScriptInTab").mockImplementation();
});
afterEach(() => {
jest.resetModules();
jest.clearAllMocks();
});
describe("injectFido2ContentScripts", () => {
const fido2ContentScript = "content/fido2/content-script.js";
const defaultExecuteScriptOptions = { runAt: "document_start" };
it("accepts an extension message sender and injects the fido2 scripts into the tab of the sender", async () => {
await fido2Service.injectFido2ContentScripts(sender);
expect(BrowserApi.executeScriptInTab).toHaveBeenCalledWith(tabMock.id, {
file: fido2ContentScript,
...defaultExecuteScriptOptions,
});
});
});
});

View File

@@ -0,0 +1,33 @@
import { BrowserApi } from "../../platform/browser/browser-api";
import { Fido2Service as Fido2ServiceInterface } from "./abstractions/fido2.service";
export default class Fido2Service implements Fido2ServiceInterface {
async init() {
const tabs = await BrowserApi.tabsQuery({});
tabs.forEach((tab) => {
if (tab.url?.startsWith("https")) {
this.injectFido2ContentScripts({ tab } as chrome.runtime.MessageSender);
}
});
BrowserApi.addListener(chrome.runtime.onConnect, (port) => {
if (port.name === "fido2ContentScriptReady") {
port.postMessage({ command: "fido2ContentScriptInit" });
}
});
}
/**
* Injects the FIDO2 content script into the current tab.
* @param {chrome.runtime.MessageSender} sender
* @returns {Promise<void>}
*/
async injectFido2ContentScripts(sender: chrome.runtime.MessageSender): Promise<void> {
await BrowserApi.executeScriptInTab(sender.tab.id, {
file: "content/fido2/content-script.js",
frameId: sender.frameId,
runAt: "document_start",
});
}
}

View File

@@ -171,6 +171,8 @@ const mainConfig = {
"content/notificationBar": "./src/autofill/content/notification-bar.ts",
"content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts",
"content/message_handler": "./src/autofill/content/message_handler.ts",
"content/fido2/trigger-fido2-content-script-injection":
"./src/vault/fido2/content/trigger-fido2-content-script-injection.ts",
"content/fido2/content-script": "./src/vault/fido2/content/content-script.ts",
"content/fido2/page-script": "./src/vault/fido2/content/page-script.ts",
"notification/bar": "./src/autofill/notification/bar.ts",

View File

@@ -232,8 +232,10 @@ export class AppComponent implements OnInit, OnDestroy {
case "syncStarted":
break;
case "syncCompleted":
await this.updateAppMenu();
if (message.successfully) {
this.updateAppMenu();
this.configService.triggerServerConfigFetch();
}
break;
case "openSettings":
await this.openModal<SettingsComponent>(SettingsComponent, this.settingsRef);

View File

@@ -200,7 +200,7 @@
<input
id="length"
type="number"
min="5"
[min]="passwordOptions.minLength"
max="128"
[(ngModel)]="passwordOptions.length"
(blur)="savePasswordOptions()"
@@ -208,7 +208,7 @@
<input
id="lengthRange"
type="range"
min="5"
[min]="passwordOptions.minLength"
max="128"
step="1"
[(ngModel)]="passwordOptions.length"
@@ -218,6 +218,18 @@
tabindex="-1"
/>
</div>
<div class="box-content-row" appBoxRow>
<span>{{ "passwordMinLength" | i18n }}</span>
<span class="txt-right">{{ passwordOptions.minLength }}</span>
<span
class="sr-only"
attr.aria-label="{{ 'passwordMinLength' | i18n }}"
role="status"
aria-live="polite"
>
{{ passwordOptionsMinLengthForReader$ | async }}
</span>
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="uppercase">A-Z</label>
<input
@@ -247,7 +259,8 @@
type="checkbox"
(change)="savePasswordOptions()"
[disabled]="enforcedPasswordPolicyOptions?.useNumbers"
[(ngModel)]="passwordOptions.number"
[ngModel]="passwordOptions.number"
(ngModelChange)="setPasswordOptionsNumber($event)"
attr.aria-label="{{ 'numbers' | i18n }}"
/>
</div>
@@ -258,7 +271,8 @@
type="checkbox"
(change)="savePasswordOptions()"
[disabled]="enforcedPasswordPolicyOptions?.useSpecial"
[(ngModel)]="passwordOptions.special"
[ngModel]="passwordOptions.special"
(ngModelChange)="setPasswordOptionsSpecial($event)"
attr.aria-label="{{ 'specialCharacters' | i18n }}"
/>
</div>
@@ -275,6 +289,7 @@
max="9"
(change)="savePasswordOptions()"
[(ngModel)]="passwordOptions.minNumber"
(input)="onPasswordOptionsMinNumberInput($event)"
/>
</div>
<div class="box-content-row box-content-row-input" appBoxRow>
@@ -286,6 +301,7 @@
max="9"
(change)="savePasswordOptions()"
[(ngModel)]="passwordOptions.minSpecial"
(input)="onPasswordOptionsMinSpecialInput($event)"
/>
</div>
<div class="box-content-row box-content-row-checkbox" appBoxRow>

View File

@@ -403,6 +403,9 @@
"length": {
"message": "Length"
},
"passwordMinLength": {
"message": "Minimum password length"
},
"uppercase": {
"message": "Uppercase (A-Z)"
},

View File

@@ -217,7 +217,8 @@
}
}
.img-right {
.img-right,
.txt-right {
float: right;
margin-left: 10px;
}

View File

@@ -134,7 +134,9 @@ export class AppComponent implements OnDestroy, OnInit {
case "syncStarted":
break;
case "syncCompleted":
if (message.successfully) {
this.configService.triggerServerConfigFetch();
}
break;
case "upgradeOrganization": {
const upgradeConfirmed = await this.dialogService.openSimpleDialog({

View File

@@ -109,13 +109,31 @@
id="length"
class="form-control"
type="number"
min="5"
[min]="passwordOptions.minLength"
max="128"
[(ngModel)]="passwordOptions.length"
(blur)="savePasswordOptions()"
(change)="lengthChanged()"
/>
</div>
<div class="form-group col-4">
<label for="min-length">{{ "passwordMinLength" | i18n }}</label>
<input
id="min-length"
class="form-control"
type="text"
readonly="true"
[value]="passwordOptions.length"
/>
<span
class="sr-only"
attr.aria-label="{{ 'passwordMinLength' | i18n }}"
role="status"
aria-live="polite"
>
{{ passwordOptionsMinLengthForReader$ | async }}
</span>
</div>
<div class="form-group col-4">
<label for="min-number">{{ "minNumbers" | i18n }}</label>
<input
@@ -124,8 +142,8 @@
type="number"
min="0"
max="9"
(blur)="savePasswordOptions()"
[(ngModel)]="passwordOptions.minNumber"
(input)="onPasswordOptionsMinNumberInput($event)"
(change)="minNumberChanged()"
/>
</div>
@@ -137,8 +155,8 @@
type="number"
min="0"
max="9"
(blur)="savePasswordOptions()"
[(ngModel)]="passwordOptions.minSpecial"
(input)="onPasswordOptionsMinSpecialInput($event)"
(change)="minSpecialChanged()"
/>
</div>
@@ -175,7 +193,8 @@
class="form-check-input"
type="checkbox"
(change)="savePasswordOptions()"
[(ngModel)]="passwordOptions.number"
[ngModel]="passwordOptions.number"
(ngModelChange)="setPasswordOptionsNumber($event)"
[disabled]="enforcedPasswordPolicyOptions?.useNumbers"
attr.aria-label="{{ 'numbers' | i18n }}"
/>
@@ -186,8 +205,8 @@
id="special"
class="form-check-input"
type="checkbox"
(change)="savePasswordOptions()"
[(ngModel)]="passwordOptions.special"
[ngModel]="passwordOptions.special"
(ngModelChange)="setPasswordOptionsSpecial($event)"
[disabled]="enforcedPasswordPolicyOptions?.useSpecial"
attr.aria-label="{{ 'specialCharacters' | i18n }}"
/>

View File

@@ -1150,6 +1150,9 @@
"length": {
"message": "Length"
},
"passwordMinLength": {
"message": "Minimum password length"
},
"uppercase": {
"message": "Uppercase (A-Z)",
"description": "Include uppercase letters in the password generator."

View File

@@ -1,6 +1,7 @@
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { first } from "rxjs/operators";
import { BehaviorSubject } from "rxjs";
import { debounceTime, first, map } from "rxjs/operators";
import { PasswordGeneratorPolicyOptions } from "@bitwarden/common/admin-console/models/domain/password-generator-policy-options";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -12,6 +13,7 @@ import {
PasswordGenerationServiceAbstraction,
PasswordGeneratorOptions,
} from "@bitwarden/common/tools/generator/password";
import { DefaultBoundaries } from "@bitwarden/common/tools/generator/password/password-generator-options-evaluator";
import {
UsernameGenerationServiceAbstraction,
UsernameGeneratorOptions,
@@ -40,6 +42,16 @@ export class GeneratorComponent implements OnInit {
enforcedPasswordPolicyOptions: PasswordGeneratorPolicyOptions;
usernameWebsite: string = null;
// update screen reader minimum password length with 500ms debounce
// so that the user isn't flooded with status updates
private _passwordOptionsMinLengthForReader = new BehaviorSubject<number>(
DefaultBoundaries.length.min,
);
protected passwordOptionsMinLengthForReader$ = this._passwordOptionsMinLengthForReader.pipe(
map((val) => val || DefaultBoundaries.length.min),
debounceTime(500),
);
constructor(
protected passwordGenerationService: PasswordGenerationServiceAbstraction,
protected usernameGenerationService: UsernameGenerationServiceAbstraction,
@@ -144,6 +156,44 @@ export class GeneratorComponent implements OnInit {
await this.passwordGenerationService.addHistory(this.password);
}
async onPasswordOptionsMinNumberInput($event: Event) {
// `savePasswordOptions()` replaces the null
this.passwordOptions.number = null;
await this.savePasswordOptions();
// fixes UI desync that occurs when minNumber has a fixed value
// that is reset through normalization
($event.target as HTMLInputElement).value = `${this.passwordOptions.minNumber}`;
}
async setPasswordOptionsNumber($event: boolean) {
this.passwordOptions.number = $event;
// `savePasswordOptions()` replaces the null
this.passwordOptions.minNumber = null;
await this.savePasswordOptions();
}
async onPasswordOptionsMinSpecialInput($event: Event) {
// `savePasswordOptions()` replaces the null
this.passwordOptions.special = null;
await this.savePasswordOptions();
// fixes UI desync that occurs when minSpecial has a fixed value
// that is reset through normalization
($event.target as HTMLInputElement).value = `${this.passwordOptions.minSpecial}`;
}
async setPasswordOptionsSpecial($event: boolean) {
this.passwordOptions.special = $event;
// `savePasswordOptions()` replaces the null
this.passwordOptions.minSpecial = null;
await this.savePasswordOptions();
}
async sliderInput() {
this.normalizePasswordOptions();
this.password = await this.passwordGenerationService.generatePassword(this.passwordOptions);
@@ -240,6 +290,8 @@ export class GeneratorComponent implements OnInit {
this.passwordOptions,
this.enforcedPasswordPolicyOptions,
);
this._passwordOptionsMinLengthForReader.next(this.passwordOptions.minLength);
}
private async initForwardOptions() {

View File

@@ -1,18 +1,73 @@
import Domain from "../../../platform/models/domain/domain-base";
/** Enterprise policy for the password generator.
* @see PolicyType.PasswordGenerator
*/
export class PasswordGeneratorPolicyOptions extends Domain {
defaultType = "";
/** The default kind of credential to generate */
defaultType: "password" | "passphrase" | "" = "";
/** The minimum length of generated passwords.
* When this is less than or equal to zero, it is ignored.
* If this is less than the total number of characters required by
* the policy's other settings, then it is ignored.
* This field is not used for passphrases.
*/
minLength = 0;
/** When this is true, an uppercase character must be part of
* the generated password.
* This field is not used for passphrases.
*/
useUppercase = false;
/** When this is true, a lowercase character must be part of
* the generated password. This field is not used for passphrases.
*/
useLowercase = false;
/** When this is true, at least one digit must be part of the generated
* password. This field is not used for passphrases.
*/
useNumbers = false;
/** The quantity of digits to include in the generated password.
* When this is less than or equal to zero, it is ignored.
* This field is not used for passphrases.
*/
numberCount = 0;
/** When this is true, at least one digit must be part of the generated
* password. This field is not used for passphrases.
*/
useSpecial = false;
/** The quantity of special characters to include in the generated
* password. When this is less than or equal to zero, it is ignored.
* This field is not used for passphrases.
*/
specialCount = 0;
/** The minimum number of words required by generated passphrases.
* This field is not used for passwords.
*/
minNumberWords = 0;
/** When this is true, the first letter of each word in the passphrase
* is capitalized. This field is not used for passwords.
*/
capitalize = false;
/** When this is true, a number is included within the passphrase.
* This field is not used for passwords.
*/
includeNumber = false;
/** Checks whether the policy affects the password generator.
* @returns True if at least one password or passphrase requirement has been set.
* If it returns False, then no requirements have been set and the policy should
* not be enforced.
*/
inEffect() {
return (
this.defaultType !== "" ||
@@ -28,4 +83,12 @@ export class PasswordGeneratorPolicyOptions extends Domain {
this.includeNumber
);
}
/** Creates a copy of the policy.
*/
clone() {
const policy = new PasswordGeneratorPolicyOptions();
Object.assign(policy, this);
return policy;
}
}

View File

@@ -0,0 +1,220 @@
import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options";
import {
DefaultBoundaries,
PassphraseGeneratorOptionsEvaluator,
} from "./passphrase-generator-options-evaluator";
import { PassphraseGenerationOptions } from "./password-generator-options";
describe("Password generator options builder", () => {
describe("constructor()", () => {
it("should set the policy object to a copy of the input policy", () => {
const policy = new PasswordGeneratorPolicyOptions();
policy.minLength = 10; // arbitrary change for deep equality check
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
expect(builder.policy).toEqual(policy);
expect(builder.policy).not.toBe(policy);
});
it("should set default boundaries when a default policy is used", () => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
expect(builder.numWords).toEqual(DefaultBoundaries.numWords);
});
it.each([1, 2])(
"should use the default word boundaries when they are greater than `policy.minNumberWords` (= %i)",
(minNumberWords) => {
const policy = new PasswordGeneratorPolicyOptions();
policy.minNumberWords = minNumberWords;
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
expect(builder.numWords).toEqual(DefaultBoundaries.numWords);
},
);
it.each([8, 12, 18])(
"should use `policy.minNumberWords` (= %i) when it is greater than the default minimum words",
(minNumberWords) => {
const policy = new PasswordGeneratorPolicyOptions();
policy.minNumberWords = minNumberWords;
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
expect(builder.numWords.min).toEqual(minNumberWords);
expect(builder.numWords.max).toEqual(DefaultBoundaries.numWords.max);
},
);
it.each([150, 300, 9000])(
"should use `policy.minNumberWords` (= %i) when it is greater than the default boundaries",
(minNumberWords) => {
const policy = new PasswordGeneratorPolicyOptions();
policy.minNumberWords = minNumberWords;
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
expect(builder.numWords.min).toEqual(minNumberWords);
expect(builder.numWords.max).toEqual(minNumberWords);
},
);
});
describe("applyPolicy(options)", () => {
// All tests should freeze the options to ensure they are not modified
it("should set `capitalize` to `false` when the policy does not override it", () => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({});
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.capitalize).toBe(false);
});
it("should set `capitalize` to `true` when the policy overrides it", () => {
const policy = new PasswordGeneratorPolicyOptions();
policy.capitalize = true;
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ capitalize: false });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.capitalize).toBe(true);
});
it("should set `includeNumber` to false when the policy does not override it", () => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({});
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.includeNumber).toBe(false);
});
it("should set `includeNumber` to true when the policy overrides it", () => {
const policy = new PasswordGeneratorPolicyOptions();
policy.includeNumber = true;
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ includeNumber: false });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.includeNumber).toBe(true);
});
it("should set `numWords` to the minimum value when it isn't supplied", () => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({});
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.numWords).toBe(builder.numWords.min);
});
it.each([1, 2])(
"should set `numWords` (= %i) to the minimum value when it is less than the minimum",
(numWords) => {
expect(numWords).toBeLessThan(DefaultBoundaries.numWords.min);
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ numWords });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.numWords).toBe(builder.numWords.min);
},
);
it.each([3, 8, 18, 20])(
"should set `numWords` (= %i) to the input value when it is within the boundaries",
(numWords) => {
expect(numWords).toBeGreaterThanOrEqual(DefaultBoundaries.numWords.min);
expect(numWords).toBeLessThanOrEqual(DefaultBoundaries.numWords.max);
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ numWords });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.numWords).toBe(numWords);
},
);
it.each([21, 30, 50, 100])(
"should set `numWords` (= %i) to the maximum value when it is greater than the maximum",
(numWords) => {
expect(numWords).toBeGreaterThan(DefaultBoundaries.numWords.max);
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ numWords });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.numWords).toBe(builder.numWords.max);
},
);
it("should preserve unknown properties", () => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({
unknown: "property",
another: "unknown property",
}) as PassphraseGenerationOptions;
const sanitizedOptions: any = builder.applyPolicy(options);
expect(sanitizedOptions.unknown).toEqual("property");
expect(sanitizedOptions.another).toEqual("unknown property");
});
});
describe("sanitize(options)", () => {
// All tests should freeze the options to ensure they are not modified
it("should return the input options without altering them", () => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ wordSeparator: "%" });
const sanitizedOptions = builder.sanitize(options);
expect(sanitizedOptions).toEqual(options);
});
it("should set `wordSeparator` to '-' when it isn't supplied and there is no policy override", () => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({});
const sanitizedOptions = builder.sanitize(options);
expect(sanitizedOptions.wordSeparator).toEqual("-");
});
it("should preserve unknown properties", () => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
const options = Object.freeze({
unknown: "property",
another: "unknown property",
}) as PassphraseGenerationOptions;
const sanitizedOptions: any = builder.sanitize(options);
expect(sanitizedOptions.unknown).toEqual("property");
expect(sanitizedOptions.another).toEqual("unknown property");
});
});
});

View File

@@ -0,0 +1,105 @@
import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options";
import { PassphraseGenerationOptions } from "./password-generator-options";
type Boundary = {
readonly min: number;
readonly max: number;
};
function initializeBoundaries() {
const numWords = Object.freeze({
min: 3,
max: 20,
});
return Object.freeze({
numWords,
});
}
/** Immutable default boundaries for passphrase generation.
* These are used when the policy does not override a value.
*/
export const DefaultBoundaries = initializeBoundaries();
/** Enforces policy for passphrase generation options.
*/
export class PassphraseGeneratorOptionsEvaluator {
// This design is not ideal, but it is a step towards a more robust passphrase
// generator. Ideally, `sanitize` would be implemented on an options class,
// and `applyPolicy` would be implemented on a policy class, "mise en place".
//
// The current design of the passphrase generator, unfortunately, would require
// a substantial rewrite to make this feasible. Hopefully this change can be
// applied when the passphrase generator is ported to rust.
/** Policy applied by the evaluator.
*/
readonly policy: PasswordGeneratorPolicyOptions;
/** Boundaries for the number of words allowed in the password.
*/
readonly numWords: Boundary;
/** Instantiates the evaluator.
* @param policy The policy applied by the evaluator. When this conflicts with
* the defaults, the policy takes precedence.
*/
constructor(policy: PasswordGeneratorPolicyOptions) {
function createBoundary(value: number, defaultBoundary: Boundary): Boundary {
const boundary = {
min: Math.max(defaultBoundary.min, value),
max: Math.max(defaultBoundary.max, value),
};
return boundary;
}
this.policy = policy.clone();
this.numWords = createBoundary(policy.minNumberWords, DefaultBoundaries.numWords);
}
/** Apply policy to the input options.
* @param options The options to build from. These options are not altered.
* @returns A new password generation request with policy applied.
*/
applyPolicy(options: PassphraseGenerationOptions): PassphraseGenerationOptions {
function fitToBounds(value: number, boundaries: Boundary) {
const { min, max } = boundaries;
const withUpperBound = Math.min(value ?? boundaries.min, max);
const withLowerBound = Math.max(withUpperBound, min);
return withLowerBound;
}
// apply policy overrides
const capitalize = this.policy.capitalize || options.capitalize || false;
const includeNumber = this.policy.includeNumber || options.includeNumber || false;
// apply boundaries
const numWords = fitToBounds(options.numWords, this.numWords);
return {
...options,
numWords,
capitalize,
includeNumber,
};
}
/** Ensures internal options consistency.
* @param options The options to cascade. These options are not altered.
* @returns A passphrase generation request with cascade applied.
*/
sanitize(options: PassphraseGenerationOptions): PassphraseGenerationOptions {
// ensure words are separated by a single character
const wordSeparator = options.wordSeparator?.[0] ?? "-";
return {
...options,
wordSeparator,
};
}
}

View File

@@ -7,11 +7,14 @@ import { EFFLongWordList } from "../../../platform/misc/wordlist";
import { EncString } from "../../../platform/models/domain/enc-string";
import { GeneratedPasswordHistory } from "./generated-password-history";
import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator";
import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction";
import { PasswordGeneratorOptions } from "./password-generator-options";
import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator";
const DefaultOptions: PasswordGeneratorOptions = {
length: 14,
minLength: 5,
ambiguous: false,
number: true,
minNumber: 1,
@@ -28,6 +31,8 @@ const DefaultOptions: PasswordGeneratorOptions = {
includeNumber: false,
};
const DefaultPolicy = new PasswordGeneratorPolicyOptions();
const MaxPasswordsInHistory = 100;
export class PasswordGenerationService implements PasswordGenerationServiceAbstraction {
@@ -38,20 +43,12 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
) {}
async generatePassword(options: PasswordGeneratorOptions): Promise<string> {
// overload defaults with given options
const o = Object.assign({}, DefaultOptions, options);
if (o.type === "passphrase") {
return this.generatePassphrase(options);
if ((options.type ?? DefaultOptions.type) === "passphrase") {
return this.generatePassphrase({ ...DefaultOptions, ...options });
}
// sanitize
this.sanitizePasswordLength(o, true);
const minLength: number = o.minUppercase + o.minLowercase + o.minNumber + o.minSpecial;
if (o.length < minLength) {
o.length = minLength;
}
const evaluator = new PasswordGeneratorOptionsEvaluator(DefaultPolicy);
const o = evaluator.sanitize({ ...DefaultOptions, ...options });
const positions: string[] = [];
if (o.lowercase && o.minLowercase > 0) {
@@ -144,7 +141,8 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
}
async generatePassphrase(options: PasswordGeneratorOptions): Promise<string> {
const o = Object.assign({}, DefaultOptions, options);
const evaluator = new PassphraseGeneratorOptionsEvaluator(DefaultPolicy);
const o = evaluator.sanitize({ ...DefaultOptions, ...options });
if (o.numWords == null || o.numWords <= 2) {
o.numWords = DefaultOptions.numWords;
@@ -192,65 +190,25 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
async enforcePasswordGeneratorPoliciesOnOptions(
options: PasswordGeneratorOptions,
): Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]> {
let enforcedPolicyOptions = await this.getPasswordGeneratorPolicyOptions();
if (enforcedPolicyOptions != null) {
if (options.length < enforcedPolicyOptions.minLength) {
options.length = enforcedPolicyOptions.minLength;
}
if (enforcedPolicyOptions.useUppercase) {
options.uppercase = true;
}
if (enforcedPolicyOptions.useLowercase) {
options.lowercase = true;
}
if (enforcedPolicyOptions.useNumbers) {
options.number = true;
}
if (options.minNumber < enforcedPolicyOptions.numberCount) {
options.minNumber = enforcedPolicyOptions.numberCount;
}
if (enforcedPolicyOptions.useSpecial) {
options.special = true;
}
if (options.minSpecial < enforcedPolicyOptions.specialCount) {
options.minSpecial = enforcedPolicyOptions.specialCount;
}
// Must normalize these fields because the receiving call expects all options to pass the current rules
if (options.minSpecial + options.minNumber > options.length) {
options.minSpecial = options.length - options.minNumber;
}
if (options.numWords < enforcedPolicyOptions.minNumberWords) {
options.numWords = enforcedPolicyOptions.minNumberWords;
}
if (enforcedPolicyOptions.capitalize) {
options.capitalize = true;
}
if (enforcedPolicyOptions.includeNumber) {
options.includeNumber = true;
}
let policy = await this.getPasswordGeneratorPolicyOptions();
policy = policy ?? new PasswordGeneratorPolicyOptions();
// Force default type if password/passphrase selected via policy
if (
enforcedPolicyOptions.defaultType === "password" ||
enforcedPolicyOptions.defaultType === "passphrase"
) {
options.type = enforcedPolicyOptions.defaultType;
if (policy.defaultType === "password" || policy.defaultType === "passphrase") {
options.type = policy.defaultType;
}
} else {
// UI layer expects an instantiated object to prevent more explicit null checks
enforcedPolicyOptions = new PasswordGeneratorPolicyOptions();
}
return [options, enforcedPolicyOptions];
const evaluator = options.type
? new PasswordGeneratorOptionsEvaluator(policy)
: new PassphraseGeneratorOptionsEvaluator(policy);
// Ensure the options to pass the current rules
const withPolicy = evaluator.applyPolicy(options);
const sanitized = evaluator.sanitize(withPolicy);
// callers assume this function updates the options parameter
const result = Object.assign(options, sanitized);
return [result, policy];
}
async getPasswordGeneratorPolicyOptions(): Promise<PasswordGeneratorPolicyOptions> {
@@ -389,62 +347,17 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
options: PasswordGeneratorOptions,
enforcedPolicyOptions: PasswordGeneratorPolicyOptions,
) {
options.minLowercase = 0;
options.minUppercase = 0;
const evaluator = options.type
? new PasswordGeneratorOptionsEvaluator(enforcedPolicyOptions)
: new PassphraseGeneratorOptionsEvaluator(enforcedPolicyOptions);
if (!options.length || options.length < 5) {
options.length = 5;
} else if (options.length > 128) {
options.length = 128;
}
const evaluatedOptions = evaluator.applyPolicy(options);
const santizedOptions = evaluator.sanitize(evaluatedOptions);
if (options.length < enforcedPolicyOptions.minLength) {
options.length = enforcedPolicyOptions.minLength;
}
// callers assume this function updates the options parameter
Object.assign(options, santizedOptions);
if (!options.minNumber) {
options.minNumber = 0;
} else if (options.minNumber > options.length) {
options.minNumber = options.length;
} else if (options.minNumber > 9) {
options.minNumber = 9;
}
if (options.minNumber < enforcedPolicyOptions.numberCount) {
options.minNumber = enforcedPolicyOptions.numberCount;
}
if (!options.minSpecial) {
options.minSpecial = 0;
} else if (options.minSpecial > options.length) {
options.minSpecial = options.length;
} else if (options.minSpecial > 9) {
options.minSpecial = 9;
}
if (options.minSpecial < enforcedPolicyOptions.specialCount) {
options.minSpecial = enforcedPolicyOptions.specialCount;
}
if (options.minSpecial + options.minNumber > options.length) {
options.minSpecial = options.length - options.minNumber;
}
if (options.numWords == null || options.length < 3) {
options.numWords = 3;
} else if (options.numWords > 20) {
options.numWords = 20;
}
if (options.numWords < enforcedPolicyOptions.minNumberWords) {
options.numWords = enforcedPolicyOptions.minNumberWords;
}
if (options.wordSeparator != null && options.wordSeparator.length > 1) {
options.wordSeparator = options.wordSeparator[0];
}
this.sanitizePasswordLength(options, false);
return options;
}
private capitalize(str: string) {
@@ -505,54 +418,4 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
[array[i], array[j]] = [array[j], array[i]];
}
}
private sanitizePasswordLength(options: any, forGeneration: boolean) {
let minUppercaseCalc = 0;
let minLowercaseCalc = 0;
let minNumberCalc: number = options.minNumber;
let minSpecialCalc: number = options.minSpecial;
if (options.uppercase && options.minUppercase <= 0) {
minUppercaseCalc = 1;
} else if (!options.uppercase) {
minUppercaseCalc = 0;
}
if (options.lowercase && options.minLowercase <= 0) {
minLowercaseCalc = 1;
} else if (!options.lowercase) {
minLowercaseCalc = 0;
}
if (options.number && options.minNumber <= 0) {
minNumberCalc = 1;
} else if (!options.number) {
minNumberCalc = 0;
}
if (options.special && options.minSpecial <= 0) {
minSpecialCalc = 1;
} else if (!options.special) {
minSpecialCalc = 0;
}
// This should never happen but is a final safety net
if (!options.length || options.length < 1) {
options.length = 10;
}
const minLength: number = minUppercaseCalc + minLowercaseCalc + minNumberCalc + minSpecialCalc;
// Normalize and Generation both require this modification
if (options.length < minLength) {
options.length = minLength;
}
// Apply other changes if the options object passed in is for generation
if (forGeneration) {
options.minUppercase = minUppercaseCalc;
options.minLowercase = minLowercaseCalc;
options.minNumber = minNumberCalc;
options.minSpecial = minSpecialCalc;
}
}
}

View File

@@ -0,0 +1,703 @@
import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options";
import { PasswordGenerationOptions } from "./password-generator-options";
import {
DefaultBoundaries,
PasswordGeneratorOptionsEvaluator,
} from "./password-generator-options-evaluator";
describe("Password generator options builder", () => {
const defaultOptions = Object.freeze({ minLength: 0 });
describe("constructor()", () => {
it("should set the policy object to a copy of the input policy", () => {
const policy = new PasswordGeneratorPolicyOptions();
policy.minLength = 10; // arbitrary change for deep equality check
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.policy).toEqual(policy);
expect(builder.policy).not.toBe(policy);
});
it("should set default boundaries when a default policy is used", () => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.length).toEqual(DefaultBoundaries.length);
expect(builder.minDigits).toEqual(DefaultBoundaries.minDigits);
expect(builder.minSpecialCharacters).toEqual(DefaultBoundaries.minSpecialCharacters);
});
it.each([1, 2, 3, 4])(
"should use the default length boundaries when they are greater than `policy.minLength` (= %i)",
(minLength) => {
expect(minLength).toBeLessThan(DefaultBoundaries.length.min);
const policy = new PasswordGeneratorPolicyOptions();
policy.minLength = minLength;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.length).toEqual(DefaultBoundaries.length);
},
);
it.each([8, 20, 100])(
"should use `policy.minLength` (= %i) when it is greater than the default minimum length",
(expectedLength) => {
expect(expectedLength).toBeGreaterThan(DefaultBoundaries.length.min);
expect(expectedLength).toBeLessThanOrEqual(DefaultBoundaries.length.max);
const policy = new PasswordGeneratorPolicyOptions();
policy.minLength = expectedLength;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.length.min).toEqual(expectedLength);
expect(builder.length.max).toEqual(DefaultBoundaries.length.max);
},
);
it.each([150, 300, 9000])(
"should use `policy.minLength` (= %i) when it is greater than the default boundaries",
(expectedLength) => {
expect(expectedLength).toBeGreaterThan(DefaultBoundaries.length.max);
const policy = new PasswordGeneratorPolicyOptions();
policy.minLength = expectedLength;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.length.min).toEqual(expectedLength);
expect(builder.length.max).toEqual(expectedLength);
},
);
it.each([3, 5, 8, 9])(
"should use `policy.numberCount` (= %i) when it is greater than the default minimum digits",
(expectedMinDigits) => {
expect(expectedMinDigits).toBeGreaterThan(DefaultBoundaries.minDigits.min);
expect(expectedMinDigits).toBeLessThanOrEqual(DefaultBoundaries.minDigits.max);
const policy = new PasswordGeneratorPolicyOptions();
policy.numberCount = expectedMinDigits;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.minDigits.min).toEqual(expectedMinDigits);
expect(builder.minDigits.max).toEqual(DefaultBoundaries.minDigits.max);
},
);
it.each([10, 20, 400])(
"should use `policy.numberCount` (= %i) when it is greater than the default digit boundaries",
(expectedMinDigits) => {
expect(expectedMinDigits).toBeGreaterThan(DefaultBoundaries.minDigits.max);
const policy = new PasswordGeneratorPolicyOptions();
policy.numberCount = expectedMinDigits;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.minDigits.min).toEqual(expectedMinDigits);
expect(builder.minDigits.max).toEqual(expectedMinDigits);
},
);
it.each([2, 4, 6])(
"should use `policy.specialCount` (= %i) when it is greater than the default minimum special characters",
(expectedSpecialCharacters) => {
expect(expectedSpecialCharacters).toBeGreaterThan(
DefaultBoundaries.minSpecialCharacters.min,
);
expect(expectedSpecialCharacters).toBeLessThanOrEqual(
DefaultBoundaries.minSpecialCharacters.max,
);
const policy = new PasswordGeneratorPolicyOptions();
policy.specialCount = expectedSpecialCharacters;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.minSpecialCharacters.min).toEqual(expectedSpecialCharacters);
expect(builder.minSpecialCharacters.max).toEqual(
DefaultBoundaries.minSpecialCharacters.max,
);
},
);
it.each([10, 20, 400])(
"should use `policy.specialCount` (= %i) when it is greater than the default special characters boundaries",
(expectedSpecialCharacters) => {
expect(expectedSpecialCharacters).toBeGreaterThan(
DefaultBoundaries.minSpecialCharacters.max,
);
const policy = new PasswordGeneratorPolicyOptions();
policy.specialCount = expectedSpecialCharacters;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.minSpecialCharacters.min).toEqual(expectedSpecialCharacters);
expect(builder.minSpecialCharacters.max).toEqual(expectedSpecialCharacters);
},
);
it.each([
[8, 6, 2],
[6, 2, 4],
[16, 8, 8],
])(
"should ensure the minimum length (= %i) is at least the sum of minimums (= %i + %i)",
(expectedLength, numberCount, specialCount) => {
expect(expectedLength).toBeGreaterThanOrEqual(DefaultBoundaries.length.min);
const policy = new PasswordGeneratorPolicyOptions();
policy.numberCount = numberCount;
policy.specialCount = specialCount;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.length.min).toBeGreaterThanOrEqual(expectedLength);
},
);
});
describe("applyPolicy(options)", () => {
// All tests should freeze the options to ensure they are not modified
it.each([
[false, false],
[true, true],
[false, undefined],
])(
"should set `options.uppercase` to '%s' when `policy.useUppercase` is false and `options.uppercase` is '%s'",
(expectedUppercase, uppercase) => {
const policy = new PasswordGeneratorPolicyOptions();
policy.useUppercase = false;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, uppercase });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.uppercase).toEqual(expectedUppercase);
},
);
it.each([false, true, undefined])(
"should set `options.uppercase` (= %s) to true when `policy.useUppercase` is true",
(uppercase) => {
const policy = new PasswordGeneratorPolicyOptions();
policy.useUppercase = true;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, uppercase });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.uppercase).toEqual(true);
},
);
it.each([
[false, false],
[true, true],
[false, undefined],
])(
"should set `options.lowercase` to '%s' when `policy.useLowercase` is false and `options.lowercase` is '%s'",
(expectedLowercase, lowercase) => {
const policy = new PasswordGeneratorPolicyOptions();
policy.useLowercase = false;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, lowercase });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.lowercase).toEqual(expectedLowercase);
},
);
it.each([false, true, undefined])(
"should set `options.lowercase` (= %s) to true when `policy.useLowercase` is true",
(lowercase) => {
const policy = new PasswordGeneratorPolicyOptions();
policy.useLowercase = true;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, lowercase });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.lowercase).toEqual(true);
},
);
it.each([
[false, false],
[true, true],
[false, undefined],
])(
"should set `options.number` to '%s' when `policy.useNumbers` is false and `options.number` is '%s'",
(expectedNumber, number) => {
const policy = new PasswordGeneratorPolicyOptions();
policy.useNumbers = false;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, number });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.number).toEqual(expectedNumber);
},
);
it.each([false, true, undefined])(
"should set `options.number` (= %s) to true when `policy.useNumbers` is true",
(number) => {
const policy = new PasswordGeneratorPolicyOptions();
policy.useNumbers = true;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, number });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.number).toEqual(true);
},
);
it.each([
[false, false],
[true, true],
[false, undefined],
])(
"should set `options.special` to '%s' when `policy.useSpecial` is false and `options.special` is '%s'",
(expectedSpecial, special) => {
const policy = new PasswordGeneratorPolicyOptions();
policy.useSpecial = false;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, special });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.special).toEqual(expectedSpecial);
},
);
it.each([false, true, undefined])(
"should set `options.special` (= %s) to true when `policy.useSpecial` is true",
(special) => {
const policy = new PasswordGeneratorPolicyOptions();
policy.useSpecial = true;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, special });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.special).toEqual(true);
},
);
it.each([1, 2, 3, 4])(
"should set `options.length` (= %i) to the minimum it is less than the minimum length",
(length) => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(length).toBeLessThan(builder.length.min);
const options = Object.freeze({ ...defaultOptions, length });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.length).toEqual(builder.length.min);
},
);
it.each([5, 10, 50, 100, 128])(
"should not change `options.length` (= %i) when it is within the boundaries",
(length) => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(length).toBeGreaterThanOrEqual(builder.length.min);
expect(length).toBeLessThanOrEqual(builder.length.max);
const options = Object.freeze({ ...defaultOptions, length });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.length).toEqual(length);
},
);
it.each([129, 500, 9000])(
"should set `options.length` (= %i) to the maximum length when it is exceeded",
(length) => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(length).toBeGreaterThan(builder.length.max);
const options = Object.freeze({ ...defaultOptions, length });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.length).toEqual(builder.length.max);
},
);
it.each([
[true, 1],
[true, 3],
[true, 600],
[false, 0],
[false, -2],
[false, -600],
])(
"should set `options.number === %s` when `options.minNumber` (= %i) is set to a value greater than 0",
(expectedNumber, minNumber) => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, minNumber });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.number).toEqual(expectedNumber);
},
);
it("should set `options.minNumber` to the minimum value when `options.number` is true", () => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, number: true });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.min);
});
it("should set `options.minNumber` to 0 when `options.number` is false", () => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, number: false });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.minNumber).toEqual(0);
});
it.each([1, 2, 3, 4])(
"should set `options.minNumber` (= %i) to the minimum it is less than the minimum number",
(minNumber) => {
const policy = new PasswordGeneratorPolicyOptions();
policy.numberCount = 5; // arbitrary value greater than minNumber
expect(minNumber).toBeLessThan(policy.numberCount);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, minNumber });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.min);
},
);
it.each([1, 3, 5, 7, 9])(
"should not change `options.minNumber` (= %i) when it is within the boundaries",
(minNumber) => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(minNumber).toBeGreaterThanOrEqual(builder.minDigits.min);
expect(minNumber).toBeLessThanOrEqual(builder.minDigits.max);
const options = Object.freeze({ ...defaultOptions, minNumber });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.minNumber).toEqual(minNumber);
},
);
it.each([10, 20, 400])(
"should set `options.minNumber` (= %i) to the maximum digit boundary when it is exceeded",
(minNumber) => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(minNumber).toBeGreaterThan(builder.minDigits.max);
const options = Object.freeze({ ...defaultOptions, minNumber });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.max);
},
);
it.each([
[true, 1],
[true, 3],
[true, 600],
[false, 0],
[false, -2],
[false, -600],
])(
"should set `options.special === %s` when `options.minSpecial` (= %i) is set to a value greater than 0",
(expectedSpecial, minSpecial) => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, minSpecial });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.special).toEqual(expectedSpecial);
},
);
it("should set `options.minSpecial` to the minimum value when `options.special` is true", () => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, special: true });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.minSpecial).toEqual(builder.minDigits.min);
});
it("should set `options.minSpecial` to 0 when `options.special` is false", () => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, special: false });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.minSpecial).toEqual(0);
});
it.each([1, 2, 3, 4])(
"should set `options.minSpecial` (= %i) to the minimum it is less than the minimum special characters",
(minSpecial) => {
const policy = new PasswordGeneratorPolicyOptions();
policy.specialCount = 5; // arbitrary value greater than minSpecial
expect(minSpecial).toBeLessThan(policy.specialCount);
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ ...defaultOptions, minSpecial });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.minSpecial).toEqual(builder.minSpecialCharacters.min);
},
);
it.each([1, 3, 5, 7, 9])(
"should not change `options.minSpecial` (= %i) when it is within the boundaries",
(minSpecial) => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(minSpecial).toBeGreaterThanOrEqual(builder.minSpecialCharacters.min);
expect(minSpecial).toBeLessThanOrEqual(builder.minSpecialCharacters.max);
const options = Object.freeze({ ...defaultOptions, minSpecial });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.minSpecial).toEqual(minSpecial);
},
);
it.each([10, 20, 400])(
"should set `options.minSpecial` (= %i) to the maximum special character boundary when it is exceeded",
(minSpecial) => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(minSpecial).toBeGreaterThan(builder.minSpecialCharacters.max);
const options = Object.freeze({ ...defaultOptions, minSpecial });
const sanitizedOptions = builder.applyPolicy(options);
expect(sanitizedOptions.minSpecial).toEqual(builder.minSpecialCharacters.max);
},
);
it("should preserve unknown properties", () => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({
unknown: "property",
another: "unknown property",
}) as PasswordGenerationOptions;
const sanitizedOptions: any = builder.applyPolicy(options);
expect(sanitizedOptions.unknown).toEqual("property");
expect(sanitizedOptions.another).toEqual("unknown property");
});
});
describe("sanitize(options)", () => {
// All tests should freeze the options to ensure they are not modified
it.each([
[1, true],
[0, false],
])(
"should output `options.minLowercase === %i` when `options.lowercase` is %s",
(expectedMinLowercase, lowercase) => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ lowercase, ...defaultOptions });
const actual = builder.sanitize(options);
expect(actual.minLowercase).toEqual(expectedMinLowercase);
},
);
it.each([
[1, true],
[0, false],
])(
"should output `options.minUppercase === %i` when `options.uppercase` is %s",
(expectedMinUppercase, uppercase) => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ uppercase, ...defaultOptions });
const actual = builder.sanitize(options);
expect(actual.minUppercase).toEqual(expectedMinUppercase);
},
);
it.each([
[1, true],
[0, false],
])(
"should output `options.minNumber === %i` when `options.number` is %s and `options.minNumber` is not set",
(expectedMinNumber, number) => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ number, ...defaultOptions });
const actual = builder.sanitize(options);
expect(actual.minNumber).toEqual(expectedMinNumber);
},
);
it.each([
[true, 3],
[true, 2],
[true, 1],
[false, 0],
])(
"should output `options.number === %s` when `options.minNumber` is %i and `options.number` is not set",
(expectedNumber, minNumber) => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ minNumber, ...defaultOptions });
const actual = builder.sanitize(options);
expect(actual.number).toEqual(expectedNumber);
},
);
it.each([
[true, 1],
[false, 0],
])(
"should output `options.minSpecial === %i` when `options.special` is %s and `options.minSpecial` is not set",
(special, expectedMinSpecial) => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ special, ...defaultOptions });
const actual = builder.sanitize(options);
expect(actual.minSpecial).toEqual(expectedMinSpecial);
},
);
it.each([
[3, true],
[2, true],
[1, true],
[0, false],
])(
"should output `options.special === %s` when `options.minSpecial` is %i and `options.special` is not set",
(minSpecial, expectedSpecial) => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({ minSpecial, ...defaultOptions });
const actual = builder.sanitize(options);
expect(actual.special).toEqual(expectedSpecial);
},
);
it.each([
[0, 0, 0, 0],
[1, 1, 0, 0],
[0, 0, 1, 1],
[1, 1, 1, 1],
])(
"should set `options.minLength` to the minimum boundary when the sum of minimums (%i + %i + %i + %i) is less than the default minimum length.",
(minLowercase, minUppercase, minNumber, minSpecial) => {
const sumOfMinimums = minLowercase + minUppercase + minNumber + minSpecial;
expect(sumOfMinimums).toBeLessThan(DefaultBoundaries.length.min);
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({
minLowercase,
minUppercase,
minNumber,
minSpecial,
...defaultOptions,
});
const actual = builder.sanitize(options);
expect(actual.minLength).toEqual(builder.length.min);
},
);
it.each([
[12, 3, 3, 3, 3],
[8, 2, 2, 2, 2],
[9, 3, 3, 3, 0],
])(
"should set `options.minLength === %i` to the sum of minimums (%i + %i + %i + %i) when the sum is at least the default minimum length.",
(expectedMinLength, minLowercase, minUppercase, minNumber, minSpecial) => {
expect(expectedMinLength).toBeGreaterThanOrEqual(DefaultBoundaries.length.min);
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({
minLowercase,
minUppercase,
minNumber,
minSpecial,
...defaultOptions,
});
const actual = builder.sanitize(options);
expect(actual.minLength).toEqual(expectedMinLength);
},
);
it("should preserve unknown properties", () => {
const policy = new PasswordGeneratorPolicyOptions();
const builder = new PasswordGeneratorOptionsEvaluator(policy);
const options = Object.freeze({
unknown: "property",
another: "unknown property",
}) as PasswordGenerationOptions;
const sanitizedOptions: any = builder.sanitize(options);
expect(sanitizedOptions.unknown).toEqual("property");
expect(sanitizedOptions.another).toEqual("unknown property");
});
});
});

View File

@@ -0,0 +1,179 @@
import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options";
import { PasswordGenerationOptions } from "./password-generator-options";
function initializeBoundaries() {
const length = Object.freeze({
min: 5,
max: 128,
});
const minDigits = Object.freeze({
min: 0,
max: 9,
});
const minSpecialCharacters = Object.freeze({
min: 0,
max: 9,
});
return Object.freeze({
length,
minDigits,
minSpecialCharacters,
});
}
/** Immutable default boundaries for password generation.
* These are used when the policy does not override a value.
*/
export const DefaultBoundaries = initializeBoundaries();
type Boundary = {
readonly min: number;
readonly max: number;
};
/** Enforces policy for password generation.
*/
export class PasswordGeneratorOptionsEvaluator {
// This design is not ideal, but it is a step towards a more robust password
// generator. Ideally, `sanitize` would be implemented on an options class,
// and `applyPolicy` would be implemented on a policy class, "mise en place".
//
// The current design of the password generator, unfortunately, would require
// a substantial rewrite to make this feasible. Hopefully this change can be
// applied when the password generator is ported to rust.
/** Boundaries for the password length. This is always large enough
* to accommodate the minimum number of digits and special characters.
*/
readonly length: Boundary;
/** Boundaries for the minimum number of digits allowed in the password.
*/
readonly minDigits: Boundary;
/** Boundaries for the minimum number of special characters allowed
* in the password.
*/
readonly minSpecialCharacters: Boundary;
/** Policy applied by the evaluator.
*/
readonly policy: PasswordGeneratorPolicyOptions;
/** Instantiates the evaluator.
* @param policy The policy applied by the evaluator. When this conflicts with
* the defaults, the policy takes precedence.
*/
constructor(policy: PasswordGeneratorPolicyOptions) {
function createBoundary(value: number, defaultBoundary: Boundary): Boundary {
const boundary = {
min: Math.max(defaultBoundary.min, value),
max: Math.max(defaultBoundary.max, value),
};
return boundary;
}
this.policy = policy.clone();
this.minDigits = createBoundary(policy.numberCount, DefaultBoundaries.minDigits);
this.minSpecialCharacters = createBoundary(
policy.specialCount,
DefaultBoundaries.minSpecialCharacters,
);
// the overall length should be at least as long as the sum of the minimums
const minConsistentLength = this.minDigits.min + this.minSpecialCharacters.min;
const minPolicyLength = policy.minLength > 0 ? policy.minLength : DefaultBoundaries.length.min;
const minLength = Math.max(minPolicyLength, minConsistentLength, DefaultBoundaries.length.min);
this.length = {
min: minLength,
max: Math.max(DefaultBoundaries.length.max, minLength),
};
}
/** Apply policy to a set of options.
* @param options The options to build from. These options are not altered.
* @returns A complete password generation request with policy applied.
* @remarks This method only applies policy overrides.
* Pass the result to `sanitize` to ensure consistency.
*/
applyPolicy(options: PasswordGenerationOptions): PasswordGenerationOptions {
function fitToBounds(value: number, boundaries: Boundary) {
const { min, max } = boundaries;
const withUpperBound = Math.min(value || 0, max);
const withLowerBound = Math.max(withUpperBound, min);
return withLowerBound;
}
// apply policy overrides
const uppercase = this.policy.useUppercase || options.uppercase || false;
const lowercase = this.policy.useLowercase || options.lowercase || false;
// these overrides can cascade numeric fields to boolean fields
const number = this.policy.useNumbers || options.number || options.minNumber > 0;
const special = this.policy.useSpecial || options.special || options.minSpecial > 0;
// apply boundaries; the boundaries can cascade boolean fields to numeric fields
const length = fitToBounds(options.length, this.length);
const minNumber = fitToBounds(options.minNumber, this.minDigits);
const minSpecial = fitToBounds(options.minSpecial, this.minSpecialCharacters);
return {
...options,
length,
uppercase,
lowercase,
number,
minNumber,
special,
minSpecial,
};
}
/** Ensures internal options consistency.
* @param options The options to cascade. These options are not altered.
* @returns A new password generation request with cascade applied.
* @remarks This method fills null and undefined values by looking at
* pairs of flags and values (e.g. `number` and `minNumber`). If the flag
* and value are inconsistent, the flag cascades to the value.
*/
sanitize(options: PasswordGenerationOptions): PasswordGenerationOptions {
function cascade(enabled: boolean, value: number): [boolean, number] {
const enabledResult = enabled ?? value > 0;
const valueResult = enabledResult ? value || 1 : 0;
return [enabledResult, valueResult];
}
const [lowercase, minLowercase] = cascade(options.lowercase, options.minLowercase);
const [uppercase, minUppercase] = cascade(options.uppercase, options.minUppercase);
const [number, minNumber] = cascade(options.number, options.minNumber);
const [special, minSpecial] = cascade(options.special, options.minSpecial);
// minimums can only increase the length
const minConsistentLength = minLowercase + minUppercase + minNumber + minSpecial;
const minLength = Math.max(minConsistentLength, this.length.min);
const length = Math.max(options.length ?? minLength, minLength);
return {
...options,
length,
minLength,
lowercase,
minLowercase,
uppercase,
minUppercase,
number,
minNumber,
special,
minSpecial,
};
}
}

View File

@@ -1,17 +1,105 @@
export type PasswordGeneratorOptions = {
length?: number;
ambiguous?: boolean;
uppercase?: boolean;
minUppercase?: number;
lowercase?: boolean;
minLowercase?: number;
number?: boolean;
minNumber?: number;
special?: boolean;
minSpecial?: number;
numWords?: number;
wordSeparator?: string;
capitalize?: boolean;
includeNumber?: boolean;
/** Request format for credential generation.
* This type includes all properties suitable for reactive data binding.
*/
export type PasswordGeneratorOptions = PasswordGenerationOptions &
PassphraseGenerationOptions & {
/** The algorithm to use for credential generation.
* Properties on @see PasswordGenerationOptions should be processed
* only when `type === "password"`.
* Properties on @see PassphraseGenerationOptions should be processed
* only when `type === "passphrase"`.
*/
type?: "password" | "passphrase";
};
/** Request format for password credential generation.
* All members of this type may be `undefined` when the user is
* generating a passphrase.
*/
export type PasswordGenerationOptions = {
/** The length of the password selected by the user */
length?: number;
/** The minimum length of the password. This defaults to 5, and increases
* to ensure `minLength` is at least as large as the sum of the other minimums.
*/
minLength?: number;
/** `true` when ambiguous characters may be included in the output.
* `false` when ambiguous characters should not be included in the output.
*/
ambiguous?: boolean;
/** `true` when uppercase ASCII characters should be included in the output
* This value defaults to `false.
*/
uppercase?: boolean;
/** The minimum number of uppercase characters to include in the output.
* The value is ignored when `uppercase` is `false`.
* The value defaults to 1 when `uppercase` is `true`.
*/
minUppercase?: number;
/** `true` when lowercase ASCII characters should be included in the output.
* This value defaults to `false`.
*/
lowercase?: boolean;
/** The minimum number of lowercase characters to include in the output.
* The value defaults to 1 when `lowercase` is `true`.
* The value defaults to 0 when `lowercase` is `false`.
*/
minLowercase?: number;
/** Whether or not to include ASCII digits in the output
* This value defaults to `true` when `minNumber` is at least 1.
* This value defaults to `false` when `minNumber` is less than 1.
*/
number?: boolean;
/** The minimum number of digits to include in the output.
* The value defaults to 1 when `number` is `true`.
* The value defaults to 0 when `number` is `false`.
*/
minNumber?: number;
/** Whether or not to include special characters in the output.
* This value defaults to `true` when `minSpecial` is at least 1.
* This value defaults to `false` when `minSpecial` is less than 1.
*/
special?: boolean;
/** The minimum number of special characters to include in the output.
* This value defaults to 1 when `special` is `true`.
* This value defaults to 0 when `special` is `false`.
*/
minSpecial?: number;
};
/** Request format for passphrase credential generation.
* The members of this type may be `undefined` when the user is
* generating a password.
*/
export type PassphraseGenerationOptions = {
/** The number of words to include in the passphrase.
* This value defaults to 4.
*/
numWords?: number;
/** The ASCII separator character to use between words in the passphrase.
* This value defaults to a dash.
* If multiple characters appear in the string, only the first character is used.
*/
wordSeparator?: string;
/** `true` when the first character of every word should be capitalized.
* This value defaults to `false`.
*/
capitalize?: boolean;
/** `true` when a number should be included in the passphrase.
* This value defaults to `false`.
*/
includeNumber?: boolean;
};

82
package-lock.json generated
View File

@@ -37,7 +37,7 @@
"bufferutil": "4.0.8",
"chalk": "4.1.2",
"commander": "7.2.0",
"core-js": "3.32.0",
"core-js": "3.34.0",
"duo_web_sdk": "github:duosecurity/duo_web_sdk",
"form-data": "4.0.0",
"https-proxy-agent": "5.0.1",
@@ -155,7 +155,7 @@
"mini-css-extract-plugin": "2.7.6",
"node-ipc": "9.2.1",
"pkg": "5.8.1",
"postcss": "8.4.31",
"postcss": "8.4.32",
"postcss-loader": "7.3.3",
"prettier": "3.1.1",
"prettier-plugin-tailwindcss": "0.5.9",
@@ -165,7 +165,7 @@
"regedit": "^3.0.3",
"remark-gfm": "3.0.1",
"rimraf": "5.0.5",
"sass": "1.65.1",
"sass": "1.69.5",
"sass-loader": "13.3.2",
"storybook": "7.3.0",
"style-loader": "3.3.3",
@@ -175,7 +175,7 @@
"tsconfig-paths-webpack-plugin": "4.1.0",
"type-fest": "2.19.0",
"typescript": "4.9.5",
"url": "0.11.1",
"url": "0.11.3",
"util": "0.12.5",
"wait-on": "7.2.0",
"webpack": "5.89.0",
@@ -893,6 +893,34 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@angular-devkit/build-angular/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/@angular-devkit/build-angular/node_modules/postcss-loader": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.0.2.tgz",
@@ -18880,9 +18908,9 @@
}
},
"node_modules/core-js": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.32.0.tgz",
"integrity": "sha512-rd4rYZNlF3WuoYuRIDEmbR/ga9CeuWX9U05umAvgrrZoHY4Z++cp/xwPQMvUpBB4Ag6J8KfD80G0zwCyaSxDww==",
"version": "3.34.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.34.0.tgz",
"integrity": "sha512-aDdvlDder8QmY91H88GzNi9EtQi2TjvQhpCX6B1v/dAZHU1AuLgHvRh54RiOerpEhEW46Tkf+vgAViB/CWC0ag==",
"hasInstallScript": true,
"funding": {
"type": "opencollective",
@@ -33230,9 +33258,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"version": "8.4.32",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz",
"integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==",
"dev": true,
"funding": [
{
@@ -33249,7 +33277,7 @@
}
],
"dependencies": {
"nanoid": "^3.3.6",
"nanoid": "^3.3.7",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
@@ -33486,6 +33514,24 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/prebuild-install": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz",
@@ -35644,9 +35690,9 @@
}
},
"node_modules/sass": {
"version": "1.65.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.65.1.tgz",
"integrity": "sha512-9DINwtHmA41SEd36eVPQ9BJKpn7eKDQmUHmpI0y5Zv2Rcorrh0zS+cFrt050hdNbmmCNKTW3hV5mWfuegNRsEA==",
"version": "1.69.5",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.69.5.tgz",
"integrity": "sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ==",
"dev": true,
"dependencies": {
"chokidar": ">=3.0.0 <4.0.0",
@@ -39535,13 +39581,13 @@
"dev": true
},
"node_modules/url": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/url/-/url-0.11.1.tgz",
"integrity": "sha512-rWS3H04/+mzzJkv0eZ7vEDGiQbgquI1fGfOad6zKvgYQi1SzMmhl7c/DdRGxhaWrVH6z0qWITo8rpnxK/RfEhA==",
"version": "0.11.3",
"resolved": "https://registry.npmjs.org/url/-/url-0.11.3.tgz",
"integrity": "sha512-6hxOLGfZASQK/cijlZnZJTq8OXAkt/3YGfQX45vvMYXpZoo8NdWZcY73K108Jf759lS1Bv/8wXnHDTSz17dSRw==",
"dev": true,
"dependencies": {
"punycode": "^1.4.1",
"qs": "^6.11.0"
"qs": "^6.11.2"
}
},
"node_modules/url-parse": {

View File

@@ -118,7 +118,7 @@
"mini-css-extract-plugin": "2.7.6",
"node-ipc": "9.2.1",
"pkg": "5.8.1",
"postcss": "8.4.31",
"postcss": "8.4.32",
"postcss-loader": "7.3.3",
"prettier": "3.1.1",
"prettier-plugin-tailwindcss": "0.5.9",
@@ -128,7 +128,7 @@
"regedit": "^3.0.3",
"remark-gfm": "3.0.1",
"rimraf": "5.0.5",
"sass": "1.65.1",
"sass": "1.69.5",
"sass-loader": "13.3.2",
"storybook": "7.3.0",
"style-loader": "3.3.3",
@@ -138,7 +138,7 @@
"tsconfig-paths-webpack-plugin": "4.1.0",
"type-fest": "2.19.0",
"typescript": "4.9.5",
"url": "0.11.1",
"url": "0.11.3",
"util": "0.12.5",
"wait-on": "7.2.0",
"webpack": "5.89.0",
@@ -169,7 +169,7 @@
"bufferutil": "4.0.8",
"chalk": "4.1.2",
"commander": "7.2.0",
"core-js": "3.32.0",
"core-js": "3.34.0",
"duo_web_sdk": "github:duosecurity/duo_web_sdk",
"form-data": "4.0.0",
"https-proxy-agent": "5.0.1",