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: on:
push: push:
branches: branches:
- 'master' - 'main'
- 'rc' - 'rc'
paths: paths:
- 'apps/web/**' - 'apps/web/**'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
# Runs creation of Pull Requests # 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 name: Label Issue Pull Request
@@ -10,7 +10,7 @@ on:
paths-ignore: paths-ignore:
- .github/workflows/** # We don't need QA on workflow changes - .github/workflows/** # We don't need QA on workflow changes
branches: branches:
- 'master' # We only want to check when PRs target master - 'main' # We only want to check when PRs target main
jobs: jobs:
add-needs-qa-label: add-needs-qa-label:

View File

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

View File

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

View File

@@ -28,9 +28,9 @@ jobs:
- name: Branch check - name: Branch check
run: | 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 "==================================="
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 "===================================" echo "==================================="
exit 1 exit 1
fi fi
@@ -661,13 +661,13 @@ jobs:
branch: rc branch: rc
path: ${{ github.workspace }}/browser-build-artifacts 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' }} if: ${{ github.ref != 'refs/heads/rc' && github.ref != 'refs/heads/hotfix-rc' }}
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2.27.0 uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2.27.0
with: with:
workflow: build-browser.yml workflow: build-browser.yml
workflow_conclusion: success workflow_conclusion: success
branch: master branch: main
path: ${{ github.workspace }}/browser-build-artifacts path: ${{ github.workspace }}/browser-build-artifacts
- name: Unzip Safari artifact - name: Unzip Safari artifact
@@ -859,13 +859,13 @@ jobs:
branch: rc branch: rc
path: ${{ github.workspace }}/browser-build-artifacts 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' }} if: ${{ github.ref != 'refs/heads/rc' && github.ref != 'refs/heads/hotfix-rc' }}
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2.27.0 uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2.27.0
with: with:
workflow: build-browser.yml workflow: build-browser.yml
workflow_conclusion: success workflow_conclusion: success
branch: master branch: main
path: ${{ github.workspace }}/browser-build-artifacts path: ${{ github.workspace }}/browser-build-artifacts
- name: Unzip Safari artifact - name: Unzip Safari artifact

View File

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

View File

@@ -159,7 +159,7 @@ jobs:
workflow: build-web.yml workflow: build-web.yml
path: assets path: assets
workflow_conclusion: success workflow_conclusion: success
branch: master branch: main
artifacts: web-*-cloud-COMMERCIAL.zip artifacts: web-*-cloud-COMMERCIAL.zip
- name: Unzip build asset - name: Unzip build asset
@@ -196,12 +196,12 @@ jobs:
gh pr create --title "Deploy v${_RELEASE_VERSION} to GitHub Pages" \ gh pr create --title "Deploy v${_RELEASE_VERSION} to GitHub Pages" \
--draft \ --draft \
--body "Deploying v${_RELEASE_VERSION}" \ --body "Deploying v${_RELEASE_VERSION}" \
--base master \ --base main \
--head "${_BRANCH}" --head "${_BRANCH}"
else else
gh pr create --title "Deploy v${_RELEASE_VERSION} to GitHub Pages" \ gh pr create --title "Deploy v${_RELEASE_VERSION} to GitHub Pages" \
--body "Deploying v${_RELEASE_VERSION}" \ --body "Deploying v${_RELEASE_VERSION}" \
--base master \ --base main \
--head "${_BRANCH}" --head "${_BRANCH}"
fi fi
@@ -243,7 +243,7 @@ jobs:
workflow: build-web.yml workflow: build-web.yml
path: apps/web/artifacts path: apps/web/artifacts
workflow_conclusion: success workflow_conclusion: success
branch: master branch: main
artifacts: "web-*-selfhosted-COMMERCIAL.zip, artifacts: "web-*-selfhosted-COMMERCIAL.zip,
web-*-selfhosted-open-source.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. 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 }}" TITLE: "Bump ${{ steps.create-branch.outputs.client }} version to ${{ inputs.version_number }}"
run: | run: |
PR_URL=$(gh pr create --title "$TITLE" \ PR_URL=$(gh pr create --title "$TITLE" \
--base "master" \ --base "main" \
--head "$PR_BRANCH" \ --head "$PR_BRANCH" \
--label "version update" \ --label "version update" \
--label "automated pr" \ --label "automated pr" \

View File

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

View File

@@ -40,7 +40,7 @@
<div class="tw-grid tw-gap-2"> <div class="tw-grid tw-gap-2">
<button <button
type="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()" (click)="lock()"
[disabled]="currentAccount.status === lockedStatus || !activeUserCanLock" [disabled]="currentAccount.status === lockedStatus || !activeUserCanLock"
[title]="!activeUserCanLock ? ('unlockMethodNeeded' | i18n) : ''" [title]="!activeUserCanLock ? ('unlockMethodNeeded' | i18n) : ''"
@@ -50,7 +50,7 @@
</button> </button>
<button <button
type="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()" (click)="logOut()"
> >
<i class="bwi bwi-sign-out tw-text-2xl" aria-hidden="true"></i> <i class="bwi bwi-sign-out tw-text-2xl" aria-hidden="true"></i>
@@ -58,7 +58,7 @@
</button> </button>
<button <button
type="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()" (click)="lockAll()"
> >
<i class="bwi bwi-lock tw-text-2xl" aria-hidden="true"></i> <i class="bwi bwi-lock tw-text-2xl" aria-hidden="true"></i>

View File

@@ -1,7 +1,7 @@
<button <button
*ngIf="account.id !== specialAccountAddId" *ngIf="account.id !== specialAccountAddId"
type="button" 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)" (click)="selectAccount(account.id)"
> >
<div class="tw-flex-shrink-0"> <div class="tw-flex-shrink-0">
@@ -18,32 +18,35 @@
<span class="tw-sr-only" *ngIf="status.text !== 'active'"> <span class="tw-sr-only" *ngIf="status.text !== 'active'">
{{ "switchToAccount" | i18n }} {{ "switchToAccount" | i18n }}
</span> </span>
<div class="tw-max-w-64 tw-truncate tw-text-main"> <div class="tw-max-w-64 tw-truncate">
{{ account.email }} {{ account.email }}
</div> </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> <span class="tw-sr-only">{{ "hostedAt" | i18n }}</span>
{{ account.server }} {{ account.server }}
</div> </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> <span class="tw-sr-only">(</span>
{{ status.text }} {{ status.text }}
<span class="tw-sr-only">)</span> <span class="tw-sr-only">)</span>
</div> </div>
</div> </div>
<div class="tw-ml-auto tw-flex-shrink-0"> <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> </div>
</button> </button>
<button <button
*ngIf="account.id === specialAccountAddId" *ngIf="account.id === specialAccountAddId"
type="button" 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)" (click)="selectAccount(account.id)"
> >
<i class="bwi bwi-plus tw-text-2xl tw-text-main" aria-hidden="true"></i> <i class="bwi bwi-plus tw-text-2xl" aria-hidden="true"></i>
<div class="tw-text-main"> <div>
{{ account.name }} {{ account.name }}
</div> </div>
</button> </button>

View File

@@ -156,7 +156,9 @@ import { BrowserSendService } from "../services/browser-send.service";
import { BrowserSettingsService } from "../services/browser-settings.service"; import { BrowserSettingsService } from "../services/browser-settings.service";
import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service"; import VaultTimeoutService from "../services/vault-timeout/vault-timeout.service";
import { BrowserFido2UserInterfaceService } from "../vault/fido2/browser-fido2-user-interface.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 { BrowserFolderService } from "../vault/services/browser-folder.service";
import Fido2Service from "../vault/services/fido2.service";
import { VaultFilterService } from "../vault/services/vault-filter.service"; import { VaultFilterService } from "../vault/services/vault-filter.service";
import CommandsBackground from "./commands.background"; import CommandsBackground from "./commands.background";
@@ -232,6 +234,7 @@ export default class MainBackground {
authRequestCryptoService: AuthRequestCryptoServiceAbstraction; authRequestCryptoService: AuthRequestCryptoServiceAbstraction;
accountService: AccountServiceAbstraction; accountService: AccountServiceAbstraction;
globalStateProvider: GlobalStateProvider; globalStateProvider: GlobalStateProvider;
fido2Service: Fido2ServiceAbstraction;
// Passed to the popup for Safari to workaround issues with theming, downloading, etc. // Passed to the popup for Safari to workaround issues with theming, downloading, etc.
backgroundWindow = window; backgroundWindow = window;
@@ -597,6 +600,7 @@ export default class MainBackground {
this.messagingService, this.messagingService,
); );
this.fido2Service = new Fido2Service();
this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService); this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService);
this.fido2AuthenticatorService = new Fido2AuthenticatorService( this.fido2AuthenticatorService = new Fido2AuthenticatorService(
this.cipherService, this.cipherService,
@@ -645,6 +649,7 @@ export default class MainBackground {
this.messagingService, this.messagingService,
this.logService, this.logService,
this.configService, this.configService,
this.fido2Service,
); );
this.nativeMessagingBackground = new NativeMessagingBackground( this.nativeMessagingBackground = new NativeMessagingBackground(
this.cryptoService, this.cryptoService,
@@ -778,6 +783,8 @@ export default class MainBackground {
await this.idleBackground.init(); await this.idleBackground.init();
await this.webRequestBackground.init(); await this.webRequestBackground.init();
await this.fido2Service.init();
if (this.platformUtilsService.isFirefox() && !this.isPrivateMode) { if (this.platformUtilsService.isFirefox() && !this.isPrivateMode) {
// Set Private Mode windows to the default icon - they do not share state with the background page // Set Private Mode windows to the default icon - they do not share state with the background page
const privateWindows = await BrowserApi.getPrivateModeWindows(); 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 { BrowserEnvironmentService } from "../platform/services/browser-environment.service";
import BrowserPlatformUtilsService from "../platform/services/browser-platform-utils.service"; import BrowserPlatformUtilsService from "../platform/services/browser-platform-utils.service";
import { AbortManager } from "../vault/background/abort-manager"; import { AbortManager } from "../vault/background/abort-manager";
import { Fido2Service } from "../vault/services/abstractions/fido2.service";
import MainBackground from "./main.background"; import MainBackground from "./main.background";
@@ -42,6 +43,7 @@ export default class RuntimeBackground {
private messagingService: MessagingService, private messagingService: MessagingService,
private logService: LogService, private logService: LogService,
private configService: ConfigServiceAbstraction, private configService: ConfigServiceAbstraction,
private fido2Service: Fido2Service,
) { ) {
// onInstalled listener must be wired up before anything else, so we do it in the ctor // onInstalled listener must be wired up before anything else, so we do it in the ctor
chrome.runtime.onInstalled.addListener((details: any) => { chrome.runtime.onInstalled.addListener((details: any) => {
@@ -257,6 +259,9 @@ export default class RuntimeBackground {
case "getClickedElementResponse": case "getClickedElementResponse":
this.platformUtilsService.copyToClipboard(msg.identifier, { window: window }); this.platformUtilsService.copyToClipboard(msg.identifier, { window: window });
break; break;
case "triggerFido2ContentScriptInjection":
await this.fido2Service.injectFido2ContentScripts(sender);
break;
case "fido2AbortRequest": case "fido2AbortRequest":
this.abortManager.abort(msg.abortedRequestId); this.abortManager.abort(msg.abortedRequestId);
break; break;

View File

@@ -17,7 +17,10 @@
"content_scripts": [ "content_scripts": [
{ {
"all_frames": true, "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:///*"], "matches": ["http://*/*", "https://*/*", "file:///*"],
"run_at": "document_start" "run_at": "document_start"
}, },

View File

@@ -341,7 +341,8 @@
} }
} }
.img-right { .img-right,
.txt-right {
float: right; float: right;
margin-left: 10px; 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 <input
id="length" id="length"
type="number" type="number"
min="5" [min]="passwordOptions.minLength"
max="128" max="128"
[(ngModel)]="passwordOptions.length" [(ngModel)]="passwordOptions.length"
(change)="savePasswordOptions()" (change)="savePasswordOptions()"
@@ -184,7 +184,7 @@
<input <input
id="lengthRange" id="lengthRange"
type="range" type="range"
min="5" [min]="passwordOptions.minLength"
max="128" max="128"
step="1" step="1"
[(ngModel)]="passwordOptions.length" [(ngModel)]="passwordOptions.length"
@@ -194,6 +194,18 @@
tabindex="-1" tabindex="-1"
/> />
</div> </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> <div class="box-content-row box-content-row-checkbox" appBoxRow>
<label for="uppercase">A-Z</label> <label for="uppercase">A-Z</label>
<input <input
@@ -221,10 +233,10 @@
<input <input
id="numbers" id="numbers"
type="checkbox" type="checkbox"
(change)="savePasswordOptions()"
attr.aria-label="{{ 'numbers' | i18n }}" attr.aria-label="{{ 'numbers' | i18n }}"
[disabled]="enforcedPasswordPolicyOptions.useNumbers" [disabled]="enforcedPasswordPolicyOptions.useNumbers"
[(ngModel)]="passwordOptions.number" [ngModel]="passwordOptions.number"
(ngModelChange)="setPasswordOptionsNumber($event)"
/> />
</div> </div>
<div class="box-content-row box-content-row-checkbox" appBoxRow> <div class="box-content-row box-content-row-checkbox" appBoxRow>
@@ -232,10 +244,10 @@
<input <input
id="special" id="special"
type="checkbox" type="checkbox"
(change)="savePasswordOptions()"
attr.aria-label="{{ 'specialCharacters' | i18n }}" attr.aria-label="{{ 'specialCharacters' | i18n }}"
[disabled]="enforcedPasswordPolicyOptions.useSpecial" [disabled]="enforcedPasswordPolicyOptions.useSpecial"
[(ngModel)]="passwordOptions.special" [ngModel]="passwordOptions.special"
(ngModelChange)="setPasswordOptionsSpecial($event)"
/> />
</div> </div>
</div> </div>
@@ -249,8 +261,8 @@
type="number" type="number"
min="0" min="0"
max="9" max="9"
(change)="savePasswordOptions()"
[(ngModel)]="passwordOptions.minNumber" [(ngModel)]="passwordOptions.minNumber"
(input)="onPasswordOptionsMinNumberInput($event)"
/> />
</div> </div>
<div class="box-content-row box-content-row-input" appBoxRow> <div class="box-content-row box-content-row-input" appBoxRow>
@@ -260,8 +272,8 @@
type="number" type="number"
min="0" min="0"
max="9" max="9"
(change)="savePasswordOptions()"
[(ngModel)]="passwordOptions.minSpecial" [(ngModel)]="passwordOptions.minSpecial"
(input)="onPasswordOptionsMinSpecialInput($event)"
/> />
</div> </div>
<div class="box-content-row box-content-row-checkbox" appBoxRow> <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; return window.location.origin === activeUserSettings.serverConfig.environment.vault;
} }
function initializeFido2ContentScript() { const messenger = Messenger.forDOMCommunication(window);
const s = document.createElement("script");
s.src = chrome.runtime.getURL("content/fido2/page-script.js");
(document.head || document.documentElement).appendChild(s);
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);
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) => { messenger.handler = async (message, abortController) => {
const requestId = Date.now().toString(); const requestId = Date.now().toString();
@@ -78,7 +94,7 @@ function initializeFido2ContentScript() {
abortController.signal.addEventListener("abort", abortHandler); abortController.signal.addEventListener("abort", abortHandler);
if (message.type === MessageType.CredentialCreationRequest) { if (message.type === MessageType.CredentialCreationRequest) {
return new Promise((resolve, reject) => { return new Promise<Message | undefined>((resolve, reject) => {
const data: CreateCredentialParams = { const data: CreateCredentialParams = {
...message.data, ...message.data,
origin: window.location.origin, origin: window.location.origin,
@@ -92,7 +108,7 @@ function initializeFido2ContentScript() {
requestId: requestId, requestId: requestId,
}, },
(response) => { (response) => {
if (response.error !== undefined) { if (response && response.error !== undefined) {
return reject(response.error); return reject(response.error);
} }
@@ -106,7 +122,7 @@ function initializeFido2ContentScript() {
} }
if (message.type === MessageType.CredentialGetRequest) { if (message.type === MessageType.CredentialGetRequest) {
return new Promise((resolve, reject) => { return new Promise<Message | undefined>((resolve, reject) => {
const data: AssertCredentialParams = { const data: AssertCredentialParams = {
...message.data, ...message.data,
origin: window.location.origin, origin: window.location.origin,
@@ -120,7 +136,7 @@ function initializeFido2ContentScript() {
requestId: requestId, requestId: requestId,
}, },
(response) => { (response) => {
if (response.error !== undefined) { if (response && response.error !== undefined) {
return reject(response.error); return reject(response.error);
} }
@@ -155,6 +171,12 @@ async function run() {
} }
initializeFido2ContentScript(); initializeFido2ContentScript();
const port = chrome.runtime.connect({ name: "fido2ContentScriptReady" });
port.onDisconnect.addListener(() => {
// Cleanup the messenger and remove the event listener
messenger.destroy();
});
} }
run(); run();

View File

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

View File

@@ -12,6 +12,12 @@ describe("Messenger", () => {
beforeEach(() => { beforeEach(() => {
// jest does not support MessageChannel // jest does not support MessageChannel
window.MessageChannel = MockMessageChannel as any; window.MessageChannel = MockMessageChannel as any;
Object.defineProperty(window, "location", {
value: {
origin: "https://bitwarden.com",
},
writable: true,
});
const channelPair = new TestChannelPair(); const channelPair = new TestChannelPair();
messengerA = new Messenger(channelPair.channelA); messengerA = new Messenger(channelPair.channelA);
@@ -27,7 +33,7 @@ describe("Messenger", () => {
const request = createRequest(); const request = createRequest();
messengerA.request(request); messengerA.request(request);
const received = handlerB.recieve(); const received = handlerB.receive();
expect(received.length).toBe(1); expect(received.length).toBe(1);
expect(received[0].message).toMatchObject(request); expect(received[0].message).toMatchObject(request);
@@ -37,7 +43,7 @@ describe("Messenger", () => {
const request = createRequest(); const request = createRequest();
const response = createResponse(); const response = createResponse();
const requestPromise = messengerA.request(request); const requestPromise = messengerA.request(request);
const received = handlerB.recieve(); const received = handlerB.receive();
received[0].respond(response); received[0].respond(response);
const returned = await requestPromise; const returned = await requestPromise;
@@ -49,7 +55,7 @@ describe("Messenger", () => {
const request = createRequest(); const request = createRequest();
const error = new Error("Test error"); const error = new Error("Test error");
const requestPromise = messengerA.request(request); const requestPromise = messengerA.request(request);
const received = handlerB.recieve(); const received = handlerB.receive();
received[0].reject(error); received[0].reject(error);
@@ -61,10 +67,60 @@ describe("Messenger", () => {
messengerA.request(createRequest(), abortController); messengerA.request(createRequest(), abortController);
abortController.abort(); abortController.abort();
const received = handlerB.recieve(); const received = handlerB.receive();
expect(received[0].abortController.signal.aborted).toBe(true); 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 }; type TestMessage = MessageWithMetadata & { testId: string };
@@ -86,11 +142,13 @@ class TestChannelPair {
this.channelA = { this.channelA = {
addEventListener: (listener) => (broadcastChannel.port1.onmessage = listener), addEventListener: (listener) => (broadcastChannel.port1.onmessage = listener),
removeEventListener: () => (broadcastChannel.port1.onmessage = null),
postMessage: (message, port) => broadcastChannel.port1.postMessage(message, port), postMessage: (message, port) => broadcastChannel.port1.postMessage(message, port),
}; };
this.channelB = { this.channelB = {
addEventListener: (listener) => (broadcastChannel.port2.onmessage = listener), addEventListener: (listener) => (broadcastChannel.port2.onmessage = listener),
removeEventListener: () => (broadcastChannel.port1.onmessage = null),
postMessage: (message, port) => broadcastChannel.port2.postMessage(message, port), postMessage: (message, port) => broadcastChannel.port2.postMessage(message, port),
}; };
} }
@@ -102,7 +160,7 @@ class TestMessageHandler {
abortController?: AbortController, abortController?: AbortController,
) => Promise<Message | undefined>; ) => Promise<Message | undefined>;
private recievedMessages: { private receivedMessages: {
message: TestMessage; message: TestMessage;
respond: (response: TestMessage) => void; respond: (response: TestMessage) => void;
reject: (error: Error) => void; reject: (error: Error) => void;
@@ -112,7 +170,7 @@ class TestMessageHandler {
constructor() { constructor() {
this.handler = (message, abortController) => this.handler = (message, abortController) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
this.recievedMessages.push({ this.receivedMessages.push({
message, message,
abortController, abortController,
respond: (response) => resolve(response), respond: (response) => resolve(response),
@@ -121,9 +179,9 @@ class TestMessageHandler {
}); });
} }
recieve() { receive() {
const received = this.recievedMessages; const received = this.receivedMessages;
this.recievedMessages = []; this.receivedMessages = [];
return received; return received;
} }
} }
@@ -144,7 +202,11 @@ class MockMessagePort<T> {
postMessage(message: T, port?: MessagePort) { postMessage(message: T, port?: MessagePort) {
this.remotePort.onmessage( 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 // 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"; import { Message, MessageType } from "./message";
const SENDER = "bitwarden-webauthn"; const SENDER = "bitwarden-webauthn";
@@ -6,15 +8,16 @@ type PostMessageFunction = (message: MessageWithMetadata, remotePort: MessagePor
export type Channel = { export type Channel = {
addEventListener: (listener: (message: MessageEvent<MessageWithMetadata>) => void) => void; addEventListener: (listener: (message: MessageEvent<MessageWithMetadata>) => void) => void;
removeEventListener: (listener: (message: MessageEvent<MessageWithMetadata>) => void) => void;
postMessage: PostMessageFunction; postMessage: PostMessageFunction;
}; };
export type Metadata = { SENDER: typeof SENDER }; export type Metadata = { SENDER: typeof SENDER; senderId: string };
export type MessageWithMetadata = Message & Metadata; export type MessageWithMetadata = Message & Metadata;
type Handler = ( type Handler = (
message: MessageWithMetadata, message: MessageWithMetadata,
abortController?: AbortController, abortController?: AbortController,
) => Promise<Message | undefined>; ) => void | Promise<Message | undefined>;
/** /**
* A class that handles communication between the page and content script. It converts * 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. * handling aborts and exceptions across separate execution contexts.
*/ */
export class Messenger { 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 * 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 * requests in the content script. Every request will then create it's own
@@ -35,14 +41,8 @@ export class Messenger {
return new Messenger({ return new Messenger({
postMessage: (message, port) => window.postMessage(message, windowOrigin, [port]), postMessage: (message, port) => window.postMessage(message, windowOrigin, [port]),
addEventListener: (listener) => addEventListener: (listener) => window.addEventListener("message", listener),
window.addEventListener("message", (event: MessageEvent<unknown>) => { removeEventListener: (listener) => window.removeEventListener("message", listener),
if (event.origin !== windowOrigin) {
return;
}
listener(event as MessageEvent<MessageWithMetadata>);
}),
}); });
} }
@@ -53,38 +53,11 @@ export class Messenger {
*/ */
handler?: Handler; handler?: Handler;
private messengerId = this.generateUniqueId();
constructor(private broadcastChannel: Channel) { constructor(private broadcastChannel: Channel) {
this.broadcastChannel.addEventListener(async (event) => { this.messageEventListener = this.createMessageEventListener();
if (this.handler === undefined) { this.broadcastChannel.addEventListener(this.messageEventListener);
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();
}
});
} }
/** /**
@@ -111,7 +84,10 @@ export class Messenger {
}); });
abortController?.signal.addEventListener("abort", abortListener); abortController?.signal.addEventListener("abort", abortListener);
this.broadcastChannel.postMessage({ ...request, SENDER }, remotePort); this.broadcastChannel.postMessage(
{ ...request, SENDER, senderId: this.messengerId },
remotePort,
);
const response = await promise; const response = await promise;
abortController?.signal.removeEventListener("abort", abortListener); abortController?.signal.removeEventListener("abort", abortListener);
@@ -127,4 +103,79 @@ export class Messenger {
localPort.close(); 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)); 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, options?: CredentialCreationOptions,
abortController?: AbortController, abortController?: AbortController,
): Promise<Credential> => { ): Promise<Credential> {
if (!isWebauthnCall(options)) { if (!isWebauthnCall(options)) {
return await browserCredentials.create(options); return await browserCredentials.create(options);
} }
@@ -88,12 +99,19 @@ navigator.credentials.create = async (
throw error; 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, options?: CredentialRequestOptions,
abortController?: AbortController, abortController?: AbortController,
): Promise<Credential> => { ): Promise<Credential> {
if (!isWebauthnCall(options)) { if (!isWebauthnCall(options)) {
return await browserCredentials.get(options); return await browserCredentials.get(options);
} }
@@ -126,7 +144,7 @@ navigator.credentials.get = async (
throw error; throw error;
} }
}; }
function isWebauthnCall(options?: CredentialCreationOptions | CredentialRequestOptions) { function isWebauthnCall(options?: CredentialCreationOptions | CredentialRequestOptions) {
return options && "publicKey" in options; return options && "publicKey" in options;
@@ -174,3 +192,23 @@ async function waitForFocus(fallbackWait = 500, timeout = 5 * 60 * 1000) {
window.clearTimeout(timeoutId); 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/notificationBar": "./src/autofill/content/notification-bar.ts",
"content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts", "content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts",
"content/message_handler": "./src/autofill/content/message_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/content-script": "./src/vault/fido2/content/content-script.ts",
"content/fido2/page-script": "./src/vault/fido2/content/page-script.ts", "content/fido2/page-script": "./src/vault/fido2/content/page-script.ts",
"notification/bar": "./src/autofill/notification/bar.ts", "notification/bar": "./src/autofill/notification/bar.ts",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core"; import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { ActivatedRoute } from "@angular/router"; 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 { PasswordGeneratorPolicyOptions } from "@bitwarden/common/admin-console/models/domain/password-generator-policy-options";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -12,6 +13,7 @@ import {
PasswordGenerationServiceAbstraction, PasswordGenerationServiceAbstraction,
PasswordGeneratorOptions, PasswordGeneratorOptions,
} from "@bitwarden/common/tools/generator/password"; } from "@bitwarden/common/tools/generator/password";
import { DefaultBoundaries } from "@bitwarden/common/tools/generator/password/password-generator-options-evaluator";
import { import {
UsernameGenerationServiceAbstraction, UsernameGenerationServiceAbstraction,
UsernameGeneratorOptions, UsernameGeneratorOptions,
@@ -40,6 +42,16 @@ export class GeneratorComponent implements OnInit {
enforcedPasswordPolicyOptions: PasswordGeneratorPolicyOptions; enforcedPasswordPolicyOptions: PasswordGeneratorPolicyOptions;
usernameWebsite: string = null; 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( constructor(
protected passwordGenerationService: PasswordGenerationServiceAbstraction, protected passwordGenerationService: PasswordGenerationServiceAbstraction,
protected usernameGenerationService: UsernameGenerationServiceAbstraction, protected usernameGenerationService: UsernameGenerationServiceAbstraction,
@@ -144,6 +156,44 @@ export class GeneratorComponent implements OnInit {
await this.passwordGenerationService.addHistory(this.password); 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() { async sliderInput() {
this.normalizePasswordOptions(); this.normalizePasswordOptions();
this.password = await this.passwordGenerationService.generatePassword(this.passwordOptions); this.password = await this.passwordGenerationService.generatePassword(this.passwordOptions);
@@ -240,6 +290,8 @@ export class GeneratorComponent implements OnInit {
this.passwordOptions, this.passwordOptions,
this.enforcedPasswordPolicyOptions, this.enforcedPasswordPolicyOptions,
); );
this._passwordOptionsMinLengthForReader.next(this.passwordOptions.minLength);
} }
private async initForwardOptions() { private async initForwardOptions() {

View File

@@ -1,18 +1,73 @@
import Domain from "../../../platform/models/domain/domain-base"; import Domain from "../../../platform/models/domain/domain-base";
/** Enterprise policy for the password generator.
* @see PolicyType.PasswordGenerator
*/
export class PasswordGeneratorPolicyOptions extends Domain { 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; 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; 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; 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; 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; 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; 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; specialCount = 0;
/** The minimum number of words required by generated passphrases.
* This field is not used for passwords.
*/
minNumberWords = 0; 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; capitalize = false;
/** When this is true, a number is included within the passphrase.
* This field is not used for passwords.
*/
includeNumber = false; 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() { inEffect() {
return ( return (
this.defaultType !== "" || this.defaultType !== "" ||
@@ -28,4 +83,12 @@ export class PasswordGeneratorPolicyOptions extends Domain {
this.includeNumber 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 { EncString } from "../../../platform/models/domain/enc-string";
import { GeneratedPasswordHistory } from "./generated-password-history"; import { GeneratedPasswordHistory } from "./generated-password-history";
import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator";
import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction"; import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction";
import { PasswordGeneratorOptions } from "./password-generator-options"; import { PasswordGeneratorOptions } from "./password-generator-options";
import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator";
const DefaultOptions: PasswordGeneratorOptions = { const DefaultOptions: PasswordGeneratorOptions = {
length: 14, length: 14,
minLength: 5,
ambiguous: false, ambiguous: false,
number: true, number: true,
minNumber: 1, minNumber: 1,
@@ -28,6 +31,8 @@ const DefaultOptions: PasswordGeneratorOptions = {
includeNumber: false, includeNumber: false,
}; };
const DefaultPolicy = new PasswordGeneratorPolicyOptions();
const MaxPasswordsInHistory = 100; const MaxPasswordsInHistory = 100;
export class PasswordGenerationService implements PasswordGenerationServiceAbstraction { export class PasswordGenerationService implements PasswordGenerationServiceAbstraction {
@@ -38,20 +43,12 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
) {} ) {}
async generatePassword(options: PasswordGeneratorOptions): Promise<string> { async generatePassword(options: PasswordGeneratorOptions): Promise<string> {
// overload defaults with given options if ((options.type ?? DefaultOptions.type) === "passphrase") {
const o = Object.assign({}, DefaultOptions, options); return this.generatePassphrase({ ...DefaultOptions, ...options });
if (o.type === "passphrase") {
return this.generatePassphrase(options);
} }
// sanitize const evaluator = new PasswordGeneratorOptionsEvaluator(DefaultPolicy);
this.sanitizePasswordLength(o, true); const o = evaluator.sanitize({ ...DefaultOptions, ...options });
const minLength: number = o.minUppercase + o.minLowercase + o.minNumber + o.minSpecial;
if (o.length < minLength) {
o.length = minLength;
}
const positions: string[] = []; const positions: string[] = [];
if (o.lowercase && o.minLowercase > 0) { if (o.lowercase && o.minLowercase > 0) {
@@ -144,7 +141,8 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
} }
async generatePassphrase(options: PasswordGeneratorOptions): Promise<string> { 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) { if (o.numWords == null || o.numWords <= 2) {
o.numWords = DefaultOptions.numWords; o.numWords = DefaultOptions.numWords;
@@ -192,65 +190,25 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
async enforcePasswordGeneratorPoliciesOnOptions( async enforcePasswordGeneratorPoliciesOnOptions(
options: PasswordGeneratorOptions, options: PasswordGeneratorOptions,
): Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]> { ): Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]> {
let enforcedPolicyOptions = await this.getPasswordGeneratorPolicyOptions(); let policy = await this.getPasswordGeneratorPolicyOptions();
if (enforcedPolicyOptions != null) { policy = policy ?? new PasswordGeneratorPolicyOptions();
if (options.length < enforcedPolicyOptions.minLength) {
options.length = enforcedPolicyOptions.minLength;
}
if (enforcedPolicyOptions.useUppercase) { // Force default type if password/passphrase selected via policy
options.uppercase = true; if (policy.defaultType === "password" || policy.defaultType === "passphrase") {
} options.type = policy.defaultType;
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;
}
// Force default type if password/passphrase selected via policy
if (
enforcedPolicyOptions.defaultType === "password" ||
enforcedPolicyOptions.defaultType === "passphrase"
) {
options.type = enforcedPolicyOptions.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> { async getPasswordGeneratorPolicyOptions(): Promise<PasswordGeneratorPolicyOptions> {
@@ -389,62 +347,17 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
options: PasswordGeneratorOptions, options: PasswordGeneratorOptions,
enforcedPolicyOptions: PasswordGeneratorPolicyOptions, enforcedPolicyOptions: PasswordGeneratorPolicyOptions,
) { ) {
options.minLowercase = 0; const evaluator = options.type
options.minUppercase = 0; ? new PasswordGeneratorOptionsEvaluator(enforcedPolicyOptions)
: new PassphraseGeneratorOptionsEvaluator(enforcedPolicyOptions);
if (!options.length || options.length < 5) { const evaluatedOptions = evaluator.applyPolicy(options);
options.length = 5; const santizedOptions = evaluator.sanitize(evaluatedOptions);
} else if (options.length > 128) {
options.length = 128;
}
if (options.length < enforcedPolicyOptions.minLength) { // callers assume this function updates the options parameter
options.length = enforcedPolicyOptions.minLength; Object.assign(options, santizedOptions);
}
if (!options.minNumber) { return options;
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);
} }
private capitalize(str: string) { private capitalize(str: string) {
@@ -505,54 +418,4 @@ export class PasswordGenerationService implements PasswordGenerationServiceAbstr
[array[i], array[j]] = [array[j], array[i]]; [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 = { /** 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; 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; ambiguous?: boolean;
/** `true` when uppercase ASCII characters should be included in the output
* This value defaults to `false.
*/
uppercase?: boolean; 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; minUppercase?: number;
/** `true` when lowercase ASCII characters should be included in the output.
* This value defaults to `false`.
*/
lowercase?: boolean; 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; 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; 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; 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; 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; minSpecial?: number;
numWords?: number; };
wordSeparator?: string;
capitalize?: boolean; /** Request format for passphrase credential generation.
includeNumber?: boolean; * The members of this type may be `undefined` when the user is
type?: "password" | "passphrase"; * 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", "bufferutil": "4.0.8",
"chalk": "4.1.2", "chalk": "4.1.2",
"commander": "7.2.0", "commander": "7.2.0",
"core-js": "3.32.0", "core-js": "3.34.0",
"duo_web_sdk": "github:duosecurity/duo_web_sdk", "duo_web_sdk": "github:duosecurity/duo_web_sdk",
"form-data": "4.0.0", "form-data": "4.0.0",
"https-proxy-agent": "5.0.1", "https-proxy-agent": "5.0.1",
@@ -155,7 +155,7 @@
"mini-css-extract-plugin": "2.7.6", "mini-css-extract-plugin": "2.7.6",
"node-ipc": "9.2.1", "node-ipc": "9.2.1",
"pkg": "5.8.1", "pkg": "5.8.1",
"postcss": "8.4.31", "postcss": "8.4.32",
"postcss-loader": "7.3.3", "postcss-loader": "7.3.3",
"prettier": "3.1.1", "prettier": "3.1.1",
"prettier-plugin-tailwindcss": "0.5.9", "prettier-plugin-tailwindcss": "0.5.9",
@@ -165,7 +165,7 @@
"regedit": "^3.0.3", "regedit": "^3.0.3",
"remark-gfm": "3.0.1", "remark-gfm": "3.0.1",
"rimraf": "5.0.5", "rimraf": "5.0.5",
"sass": "1.65.1", "sass": "1.69.5",
"sass-loader": "13.3.2", "sass-loader": "13.3.2",
"storybook": "7.3.0", "storybook": "7.3.0",
"style-loader": "3.3.3", "style-loader": "3.3.3",
@@ -175,7 +175,7 @@
"tsconfig-paths-webpack-plugin": "4.1.0", "tsconfig-paths-webpack-plugin": "4.1.0",
"type-fest": "2.19.0", "type-fest": "2.19.0",
"typescript": "4.9.5", "typescript": "4.9.5",
"url": "0.11.1", "url": "0.11.3",
"util": "0.12.5", "util": "0.12.5",
"wait-on": "7.2.0", "wait-on": "7.2.0",
"webpack": "5.89.0", "webpack": "5.89.0",
@@ -893,6 +893,34 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/@angular-devkit/build-angular/node_modules/postcss-loader": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.0.2.tgz", "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.0.2.tgz",
@@ -18880,9 +18908,9 @@
} }
}, },
"node_modules/core-js": { "node_modules/core-js": {
"version": "3.32.0", "version": "3.34.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.32.0.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.34.0.tgz",
"integrity": "sha512-rd4rYZNlF3WuoYuRIDEmbR/ga9CeuWX9U05umAvgrrZoHY4Z++cp/xwPQMvUpBB4Ag6J8KfD80G0zwCyaSxDww==", "integrity": "sha512-aDdvlDder8QmY91H88GzNi9EtQi2TjvQhpCX6B1v/dAZHU1AuLgHvRh54RiOerpEhEW46Tkf+vgAViB/CWC0ag==",
"hasInstallScript": true, "hasInstallScript": true,
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -33230,9 +33258,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.4.31", "version": "8.4.32",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -33249,7 +33277,7 @@
} }
], ],
"dependencies": { "dependencies": {
"nanoid": "^3.3.6", "nanoid": "^3.3.7",
"picocolors": "^1.0.0", "picocolors": "^1.0.0",
"source-map-js": "^1.0.2" "source-map-js": "^1.0.2"
}, },
@@ -33486,6 +33514,24 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true "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": { "node_modules/prebuild-install": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz",
@@ -35644,9 +35690,9 @@
} }
}, },
"node_modules/sass": { "node_modules/sass": {
"version": "1.65.1", "version": "1.69.5",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.65.1.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.69.5.tgz",
"integrity": "sha512-9DINwtHmA41SEd36eVPQ9BJKpn7eKDQmUHmpI0y5Zv2Rcorrh0zS+cFrt050hdNbmmCNKTW3hV5mWfuegNRsEA==", "integrity": "sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"chokidar": ">=3.0.0 <4.0.0", "chokidar": ">=3.0.0 <4.0.0",
@@ -39535,13 +39581,13 @@
"dev": true "dev": true
}, },
"node_modules/url": { "node_modules/url": {
"version": "0.11.1", "version": "0.11.3",
"resolved": "https://registry.npmjs.org/url/-/url-0.11.1.tgz", "resolved": "https://registry.npmjs.org/url/-/url-0.11.3.tgz",
"integrity": "sha512-rWS3H04/+mzzJkv0eZ7vEDGiQbgquI1fGfOad6zKvgYQi1SzMmhl7c/DdRGxhaWrVH6z0qWITo8rpnxK/RfEhA==", "integrity": "sha512-6hxOLGfZASQK/cijlZnZJTq8OXAkt/3YGfQX45vvMYXpZoo8NdWZcY73K108Jf759lS1Bv/8wXnHDTSz17dSRw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"punycode": "^1.4.1", "punycode": "^1.4.1",
"qs": "^6.11.0" "qs": "^6.11.2"
} }
}, },
"node_modules/url-parse": { "node_modules/url-parse": {

View File

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