1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-12 10:14:10 +01:00

[PM-11418] generator policy constraints (#11014)

* add constraint support to UserStateSubject
* add dynamic constraints
* implement password policy constraints
* replace policy evaluator with constraints in credential generation service
* add cascade between minNumber and minSpecial

Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com>
This commit is contained in:
✨ Audrey ✨ 2024-09-23 05:07:47 -04:00 committed by GitHub
parent 9a89ef9b4f
commit cf48db5ed1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 2034 additions and 234 deletions

View File

@ -489,6 +489,10 @@
"message": "Avoid ambiguous characters",
"description": "Label for the avoid ambiguous characters checkbox."
},
"generatorPolicyInEffect": {
"message": "Enterprise policy requirements have been applied to your generator options.",
"description": "Indicates that a policy limits the credential generator screen."
},
"searchVault": {
"message": "Search vault"
},

View File

@ -1 +1 @@
<bit-password-generator />
<tools-password-generator />

View File

@ -444,6 +444,10 @@
"ambiguous": {
"message": "Avoid ambiguous characters"
},
"generatorPolicyInEffect": {
"message": "Enterprise policy requirements have been applied to your generator options.",
"description": "Indicates that a policy limits the credential generator screen."
},
"searchCollection": {
"message": "Search collection"
},

View File

@ -1475,6 +1475,10 @@
"includeNumber": {
"message": "Include number"
},
"generatorPolicyInEffect": {
"message": "Enterprise policy requirements have been applied to your generator options.",
"description": "Indicates that a policy limits the credential generator screen."
},
"passwordHistory": {
"message": "Password history"
},

View File

@ -0,0 +1,24 @@
import { Constraints, StateConstraints } from "../types";
// The constraints type shares the properties of the state,
// but never has any members
const EMPTY_CONSTRAINTS = new Proxy<any>(Object.freeze({}), {
get() {
return {};
},
});
/** A constraint that does nothing. */
export class IdentityConstraint<State extends object> implements StateConstraints<State> {
/** Instantiate the identity constraint */
constructor() {}
readonly constraints: Readonly<Constraints<State>> = EMPTY_CONSTRAINTS;
adjust(state: State) {
return state;
}
fix(state: State) {
return state;
}
}

View File

@ -0,0 +1,27 @@
import { StateConstraints } from "../types";
import { isDynamic } from "./state-constraints-dependency";
type TestType = { foo: string };
describe("isDynamic", () => {
it("returns `true` when the constraint fits the `DynamicStateConstraints` type.", () => {
const constraint: any = {
calibrate(state: TestType): StateConstraints<TestType> {
return null;
},
};
const result = isDynamic(constraint);
expect(result).toBeTruthy();
});
it("returns `false` when the constraint fails to fit the `DynamicStateConstraints` type.", () => {
const constraint: any = {};
const result = isDynamic(constraint);
expect(result).toBeFalsy();
});
});

View File

@ -0,0 +1,29 @@
import { Observable } from "rxjs";
import { DynamicStateConstraints, StateConstraints } from "../types";
/** A pattern for types that depend upon a dynamic set of constraints.
*
* Consumers of this dependency should track the last-received state and
* apply it when application state is received or emitted. If `constraints$`
* emits an unrecoverable error, the consumer should continue using the
* last-emitted constraints. If `constraints$` completes, the consumer should
* continue using the last-emitted constraints.
*/
export type StateConstraintsDependency<State> = {
/** A stream that emits constraints when subscribed and when the
* constraints change. The stream should not emit `null` or
* `undefined`.
*/
constraints$: Observable<StateConstraints<State> | DynamicStateConstraints<State>>;
};
/** Returns `true` if the input constraint is a `DynamicStateConstraints<T>`.
* Otherwise, returns false.
* @param constraints the constraint to evaluate.
* */
export function isDynamic<State>(
constraints: StateConstraints<State> | DynamicStateConstraints<State>,
): constraints is DynamicStateConstraints<State> {
return constraints && "calibrate" in constraints;
}

View File

@ -0,0 +1,31 @@
import { Simplify } from "type-fest";
import { Dependencies, SingleUserDependency, WhenDependency } from "../dependencies";
import { StateConstraintsDependency } from "./state-constraints-dependency";
/** dependencies accepted by the user state subject */
export type UserStateSubjectDependencies<State, Dependency> = Simplify<
SingleUserDependency &
Partial<WhenDependency> &
Partial<Dependencies<Dependency>> &
Partial<StateConstraintsDependency<State>> & {
/** Compute the next stored value. If this is not set, values
* provided to `next` unconditionally override state.
* @param current the value stored in state
* @param next the value received by the user state subject's `next` member
* @param dependencies the latest value from `Dependencies<TCombine>`
* @returns the value to store in state
*/
nextValue?: (current: State, next: State, dependencies?: Dependency) => State;
/**
* Compute whether the state should update. If this is not set, values
* provided to `next` always update the state.
* @param current the value stored in state
* @param next the value received by the user state subject's `next` member
* @param dependencies the latest value from `Dependencies<TCombine>`
* @returns `true` if the value should be stored, otherwise `false`.
*/
shouldUpdate?: (value: State, next: State, dependencies?: Dependency) => boolean;
}
>;

View File

@ -2,13 +2,37 @@ import { BehaviorSubject, of, Subject } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
import { awaitAsync, FakeSingleUserState } from "../../../spec";
import { awaitAsync, FakeSingleUserState, ObservableTracker } from "../../../spec";
import { StateConstraints } from "../types";
import { UserStateSubject } from "./user-state-subject";
const SomeUser = "some user" as UserId;
type TestType = { foo: string };
function fooMaxLength(maxLength: number): StateConstraints<TestType> {
return Object.freeze({
constraints: { foo: { maxLength } },
adjust: function (state: TestType): TestType {
return {
foo: state.foo.slice(0, this.constraints.foo.maxLength),
};
},
fix: function (state: TestType): TestType {
return {
foo: `finalized|${state.foo.slice(0, this.constraints.foo.maxLength)}`,
};
},
});
}
const DynamicFooMaxLength = Object.freeze({
expected: fooMaxLength(0),
calibrate(state: TestType) {
return this.expected;
},
});
describe("UserStateSubject", () => {
describe("dependencies", () => {
it("ignores repeated when$ emissions", async () => {
@ -54,6 +78,19 @@ describe("UserStateSubject", () => {
expect(nextValue).toHaveBeenCalledTimes(1);
});
it("waits for constraints$", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new Subject<StateConstraints<TestType>>();
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
constraints$.next(fooMaxLength(3));
const [initResult] = await tracker.pauseUntilReceived(1);
expect(initResult).toEqual({ foo: "ini" });
});
});
describe("next", () => {
@ -246,6 +283,116 @@ describe("UserStateSubject", () => {
expect(nextValue).toHaveBeenCalled();
});
it("applies constraints$ on init", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
const [result] = await tracker.pauseUntilReceived(1);
expect(result).toEqual({ foo: "in" });
});
it("applies dynamic constraints", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(DynamicFooMaxLength);
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
const expected: TestType = { foo: "next" };
const emission = tracker.expectEmission();
subject.next(expected);
const actual = await emission;
expect(actual).toEqual({ foo: "" });
});
it("applies constraints$ on constraints$ emission", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
constraints$.next(fooMaxLength(1));
const [, result] = await tracker.pauseUntilReceived(2);
expect(result).toEqual({ foo: "i" });
});
it("applies constraints$ on next", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
subject.next({ foo: "next" });
const [, result] = await tracker.pauseUntilReceived(2);
expect(result).toEqual({ foo: "ne" });
});
it("applies latest constraints$ on next", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
constraints$.next(fooMaxLength(3));
subject.next({ foo: "next" });
const [, , result] = await tracker.pauseUntilReceived(3);
expect(result).toEqual({ foo: "nex" });
});
it("waits for constraints$", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new Subject<StateConstraints<TestType>>();
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
subject.next({ foo: "next" });
constraints$.next(fooMaxLength(3));
// `init` is also waiting and is processed before `next`
const [, nextResult] = await tracker.pauseUntilReceived(2);
expect(nextResult).toEqual({ foo: "nex" });
});
it("uses the last-emitted value from constraints$ when constraints$ errors", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(3));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
constraints$.error({ some: "error" });
subject.next({ foo: "next" });
const [, nextResult] = await tracker.pauseUntilReceived(1);
expect(nextResult).toEqual({ foo: "nex" });
});
it("uses the last-emitted value from constraints$ when constraints$ completes", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(3));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
constraints$.complete();
subject.next({ foo: "next" });
const [, nextResult] = await tracker.pauseUntilReceived(1);
expect(nextResult).toEqual({ foo: "nex" });
});
});
describe("error", () => {
@ -474,4 +621,150 @@ describe("UserStateSubject", () => {
expect(subject.userId).toEqual(SomeUser);
});
});
describe("withConstraints$", () => {
it("emits the next value with an empty constraint", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ });
const tracker = new ObservableTracker(subject.withConstraints$);
const expected: TestType = { foo: "next" };
const emission = tracker.expectEmission();
subject.next(expected);
const actual = await emission;
expect(actual.state).toEqual(expected);
expect(actual.constraints).toEqual({});
});
it("ceases emissions once the subject completes", async () => {
const initialState = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ });
const tracker = new ObservableTracker(subject.withConstraints$);
subject.complete();
subject.next({ foo: "ignored" });
const [result] = await tracker.pauseUntilReceived(1);
expect(result.state).toEqual(initialState);
expect(tracker.emissions.length).toEqual(1);
});
it("emits constraints$ on constraints$ emission", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$);
const expected = fooMaxLength(1);
const emission = tracker.expectEmission();
constraints$.next(expected);
const result = await emission;
expect(result.state).toEqual({ foo: "i" });
expect(result.constraints).toEqual(expected.constraints);
});
it("emits dynamic constraints", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(DynamicFooMaxLength);
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$);
const expected: TestType = { foo: "next" };
const emission = tracker.expectEmission();
subject.next(expected);
const actual = await emission;
expect(actual.state).toEqual({ foo: "" });
expect(actual.constraints).toEqual(DynamicFooMaxLength.expected.constraints);
});
it("emits constraints$ on next", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const expected = fooMaxLength(2);
const constraints$ = new BehaviorSubject(expected);
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$);
const emission = tracker.expectEmission();
subject.next({ foo: "next" });
const result = await emission;
expect(result.state).toEqual({ foo: "ne" });
expect(result.constraints).toEqual(expected.constraints);
});
it("emits the latest constraints$ on next", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$);
const expected = fooMaxLength(3);
constraints$.next(expected);
const emission = tracker.expectEmission();
subject.next({ foo: "next" });
const result = await emission;
expect(result.state).toEqual({ foo: "nex" });
expect(result.constraints).toEqual(expected.constraints);
});
it("waits for constraints$", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new Subject<StateConstraints<TestType>>();
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$);
const expected = fooMaxLength(3);
subject.next({ foo: "next" });
constraints$.next(expected);
// `init` is also waiting and is processed before `next`
const [, nextResult] = await tracker.pauseUntilReceived(2);
expect(nextResult.state).toEqual({ foo: "nex" });
expect(nextResult.constraints).toEqual(expected.constraints);
});
it("emits the last-emitted value from constraints$ when constraints$ errors", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const expected = fooMaxLength(3);
const constraints$ = new BehaviorSubject(expected);
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$);
constraints$.error({ some: "error" });
subject.next({ foo: "next" });
const [, nextResult] = await tracker.pauseUntilReceived(1);
expect(nextResult.state).toEqual({ foo: "nex" });
expect(nextResult.constraints).toEqual(expected.constraints);
});
it("emits the last-emitted value from constraints$ when constraints$ completes", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const expected = fooMaxLength(3);
const constraints$ = new BehaviorSubject(expected);
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$);
constraints$.complete();
subject.next({ foo: "next" });
const [, nextResult] = await tracker.pauseUntilReceived(1);
expect(nextResult.state).toEqual({ foo: "nex" });
expect(nextResult.constraints).toEqual(expected.constraints);
});
});
});

View File

@ -17,37 +17,20 @@ import {
startWith,
Observable,
Subscription,
last,
concat,
combineLatestWith,
catchError,
EMPTY,
} from "rxjs";
import { Simplify } from "type-fest";
import { SingleUserState } from "@bitwarden/common/platform/state";
import { Dependencies, SingleUserDependency, WhenDependency } from "../dependencies";
import { WithConstraints } from "../types";
/** dependencies accepted by the user state subject */
export type UserStateSubjectDependencies<State, Dependency> = Simplify<
SingleUserDependency &
Partial<WhenDependency> &
Partial<Dependencies<Dependency>> & {
/** Compute the next stored value. If this is not set, values
* provided to `next` unconditionally override state.
* @param current the value stored in state
* @param next the value received by the user state subject's `next` member
* @param dependencies the latest value from `Dependencies<TCombine>`
* @returns the value to store in state
*/
nextValue?: (current: State, next: State, dependencies?: Dependency) => State;
/**
* Compute whether the state should update. If this is not set, values
* provided to `next` always update the state.
* @param current the value stored in state
* @param next the value received by the user state subject's `next` member
* @param dependencies the latest value from `Dependencies<TCombine>`
* @returns `true` if the value should be stored, otherwise `false`.
*/
shouldUpdate?: (value: State, next: State, dependencies?: Dependency) => boolean;
}
>;
import { IdentityConstraint } from "./identity-state-constraint";
import { isDynamic } from "./state-constraints-dependency";
import { UserStateSubjectDependencies } from "./user-state-subject-dependencies";
/**
* Adapt a state provider to an rxjs subject.
@ -61,7 +44,7 @@ export type UserStateSubjectDependencies<State, Dependency> = Simplify<
* @template State the state stored by the subject
* @template Dependencies use-specific dependencies provided by the user.
*/
export class UserStateSubject<State, Dependencies = null>
export class UserStateSubject<State extends object, Dependencies = null>
extends Observable<State>
implements SubjectLike<State>
{
@ -99,6 +82,35 @@ export class UserStateSubject<State, Dependencies = null>
}),
distinctUntilChanged(),
);
const constraints$ = (
this.dependencies.constraints$ ?? new BehaviorSubject(new IdentityConstraint<State>())
).pipe(
// FIXME: this should probably log that an error occurred
catchError(() => EMPTY),
);
// normalize input in case this `UserStateSubject` is not the only
// observer of the backing store
const input$ = combineLatest([this.input, constraints$]).pipe(
map(([input, constraints]) => {
const calibration = isDynamic(constraints) ? constraints.calibrate(input) : constraints;
const state = calibration.adjust(input);
return state;
}),
);
// when the output subscription completes, its last-emitted value
// loops around to the input for finalization
const finalize$ = this.pipe(
last(),
combineLatestWith(constraints$),
map(([output, constraints]) => {
const calibration = isDynamic(constraints) ? constraints.calibrate(output) : constraints;
const state = calibration.fix(output);
return state;
}),
);
const updates$ = concat(input$, finalize$);
// observe completion
const whenComplete$ = when$.pipe(ignoreElements(), endWith(true));
@ -106,9 +118,24 @@ export class UserStateSubject<State, Dependencies = null>
const userIdComplete$ = this.dependencies.singleUserId$.pipe(ignoreElements(), endWith(true));
const completion$ = race(whenComplete$, inputComplete$, userIdComplete$);
// wire subscriptions
this.outputSubscription = this.state.state$.subscribe(this.output);
this.inputSubscription = combineLatest([this.input, when$, userIdAvailable$])
// wire output before input so that output normalizes the current state
// before any `next` value is processed
this.outputSubscription = this.state.state$
.pipe(
combineLatestWith(constraints$),
map(([rawState, constraints]) => {
const calibration = isDynamic(constraints)
? constraints.calibrate(rawState)
: constraints;
const state = calibration.adjust(rawState);
return {
constraints: calibration.constraints,
state,
};
}),
)
.subscribe(this.output);
this.inputSubscription = combineLatest([updates$, when$, userIdAvailable$])
.pipe(
filter(([_, when]) => when),
map(([state]) => state),
@ -144,14 +171,19 @@ export class UserStateSubject<State, Dependencies = null>
* @returns the subscription
*/
subscribe(observer?: Partial<Observer<State>> | ((value: State) => void) | null): Subscription {
return this.output.subscribe(observer);
return this.output.pipe(map((wc) => wc.state)).subscribe(observer);
}
// using subjects to ensure the right semantics are followed;
// if greater efficiency becomes desirable, consider implementing
// `SubjectLike` directly
private input = new Subject<State>();
private readonly output = new ReplaySubject<State>(1);
private readonly output = new ReplaySubject<WithConstraints<State>>(1);
/** A stream containing settings and their last-applied constraints. */
get withConstraints$() {
return this.output.asObservable();
}
private inputSubscription: Unsubscribable;
private outputSubscription: Unsubscribable;

View File

@ -2,8 +2,11 @@ import { Simplify } from "type-fest";
/** Constraints that are shared by all primitive field types */
type PrimitiveConstraint = {
/** presence indicates the field is required */
required?: true;
/** `true` indicates the field is required; otherwise the field is optional */
required?: boolean;
/** `true` indicates the field is immutable; otherwise the field is mutable */
readonly?: boolean;
};
/** Constraints that are shared by string fields */
@ -23,29 +26,108 @@ type NumberConstraints = {
/** maximum number value. When absent, min value is unbounded. */
max?: number;
/** presence indicates the field only accepts integer values */
integer?: true;
/** requires the number be a multiple of the step value */
/** requires the number be a multiple of the step value;
* this field must be a positive number. +0 and Infinity are
* prohibited. When absent, any number is accepted.
* @remarks set this to `1` to require integer values.
*/
step?: number;
};
/** Constraints that are shared by boolean fields */
type BooleanConstraint = {
/** When present, the boolean field must have the set value.
* When absent or undefined, the boolean field's value is unconstrained.
*/
requiredValue?: boolean;
};
/** Utility type that transforms a type T into its supported validators.
*/
export type Constraint<T> = PrimitiveConstraint &
(T extends string
? StringConstraints
: T extends number
? NumberConstraints
: T extends boolean
? BooleanConstraint
: never);
/** Utility type that transforms keys of T into their supported
* validators.
*/
export type Constraints<T> = {
[Key in keyof T]: Simplify<
PrimitiveConstraint &
(T[Key] extends string
? StringConstraints
: T[Key] extends number
? NumberConstraints
: never)
>;
[Key in keyof T]?: Simplify<Constraint<T[Key]>>;
};
/** Utility type that tracks whether a set of constraints was
* produced by an active policy.
*/
export type PolicyConstraints<T> = {
/** When true, the constraints were derived from an active policy. */
policyInEffect?: boolean;
} & Constraints<T>;
/** utility type for methods that evaluate constraints generically. */
export type AnyConstraint = PrimitiveConstraint & StringConstraints & NumberConstraints;
export type AnyConstraint = PrimitiveConstraint &
StringConstraints &
NumberConstraints &
BooleanConstraint;
/** Extends state message with constraints that apply to the message. */
export type WithConstraints<State> = {
/** the state */
readonly state: State;
/** the constraints enforced upon the type. */
readonly constraints: Constraints<State>;
};
/** Creates constraints that are applied automatically to application
* state.
* This type is mutually exclusive with `StateConstraints`.
*/
export type DynamicStateConstraints<State> = {
/** Creates constraints with data derived from the input state
* @param state the state from which the constraints are initialized.
* @remarks this is useful for calculating constraints that
* depend upon values from the input state. You should not send these
* constraints to the UI, because that would prevent the UI from
* offering less restrictive constraints.
*/
calibrate: (state: State) => StateConstraints<State>;
};
/** Constraints that are applied automatically to application state.
* This type is mutually exclusive with `DynamicStateConstraints`.
* @remarks this type automatically corrects incoming our outgoing
* data. If you would like to prevent invalid data from being
* applied, use an rxjs filter and evaluate `Constraints<State>`
* instead.
*/
export type StateConstraints<State> = {
/** Well-known constraints of `State` */
readonly constraints: Readonly<Constraints<State>>;
/** Enforces constraints that always hold for the emitted state.
* @remarks This is useful for enforcing "override" constraints,
* such as when a policy requires a value fall within a specific
* range.
* @param state the state pending emission from the subject.
* @return the value emitted by the subject
*/
adjust: (state: State) => State;
/** Enforces constraints that holds when the subject completes.
* @remarks This is useful for enforcing "default" constraints,
* such as when a policy requires some state is true when data is
* first subscribed, but the state may vary thereafter.
* @param state the state of the subject immediately before
* completion.
* @return the value stored to state upon completion.
*/
fix: (state: State) => State;
};
/** Options that provide contextual information about the application state
* when a generator is invoked.

View File

@ -5,7 +5,7 @@
<form class="box" [formGroup]="settings" class="tw-container">
<div class="tw-mb-4">
<bit-card>
<bit-form-field>
<bit-form-field disableMargin>
<bit-label>{{ "numWords" | i18n }}</bit-label>
<input
bitInput
@ -28,10 +28,11 @@
<input bitCheckbox formControlName="capitalize" id="capitalize" type="checkbox" />
<bit-label>{{ "capitalize" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<bit-form-control [disableMargin]="!policyInEffect">
<input bitCheckbox formControlName="includeNumber" id="include-number" type="checkbox" />
<bit-label>{{ "includeNumber" | i18n }}</bit-label>
</bit-form-control>
<p *ngIf="policyInEffect" bitTypography="helper">{{ "generatorPolicyInEffect" | i18n }}</p>
</bit-card>
</div>
</form>

View File

@ -23,7 +23,7 @@ const Controls = Object.freeze({
/** Options group for passphrases */
@Component({
standalone: true,
selector: "bit-passphrase-settings",
selector: "tools-passphrase-settings",
templateUrl: "passphrase-settings.component.html",
imports: [DependenciesModule],
})
@ -81,24 +81,22 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
this.generatorService
.policy$(Generators.Passphrase, { userId$: singleUserId$ })
.pipe(takeUntil(this.destroyed$))
.subscribe((policy) => {
.subscribe(({ constraints }) => {
this.settings
.get(Controls.numWords)
.setValidators(toValidators(Controls.numWords, Generators.Passphrase, policy));
.setValidators(toValidators(Controls.numWords, Generators.Passphrase, constraints));
this.settings
.get(Controls.wordSeparator)
.setValidators(toValidators(Controls.wordSeparator, Generators.Passphrase, policy));
.setValidators(toValidators(Controls.wordSeparator, Generators.Passphrase, constraints));
// forward word boundaries to the template (can't do it through the rx form)
// FIXME: move the boundary logic fully into the policy evaluator
this.minNumWords =
policy.numWords?.min ?? Generators.Passphrase.settings.constraints.numWords.min;
this.maxNumWords =
policy.numWords?.max ?? Generators.Passphrase.settings.constraints.numWords.max;
this.minNumWords = constraints.numWords.min;
this.maxNumWords = constraints.numWords.max;
this.policyInEffect = constraints.policyInEffect;
this.toggleEnabled(Controls.capitalize, !policy.policy.capitalize);
this.toggleEnabled(Controls.includeNumber, !policy.policy.includeNumber);
this.toggleEnabled(Controls.capitalize, !constraints.capitalize?.readonly);
this.toggleEnabled(Controls.includeNumber, !constraints.includeNumber?.readonly);
});
// now that outputs are set up, connect inputs
@ -111,11 +109,14 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
/** attribute binding for numWords[max] */
protected maxNumWords: number;
/** display binding for enterprise policy notice */
protected policyInEffect: boolean;
private toggleEnabled(setting: keyof typeof Controls, enabled: boolean) {
if (enabled) {
this.settings.get(setting).enable();
this.settings.get(setting).enable({ emitEvent: false });
} else {
this.settings.get(setting).disable();
this.settings.get(setting).disable({ emitEvent: false });
}
}

View File

@ -13,10 +13,10 @@
</bit-toggle>
</bit-toggle-group>
<bit-card class="tw-flex tw-justify-between tw-mb-4">
<div class="tw-grow">
<div class="tw-grow tw-flex tw-items-center">
<bit-color-password class="tw-font-mono" [password]="value$ | async"></bit-color-password>
</div>
<div class="tw-space-x-1 tw-flex-none tw-w-4">
<div class="tw-space-x-1">
<button type="button" bitIconButton="bwi-generate" buttonType="main" (click)="generate$.next()">
{{ "generatePassword" | i18n }}
</button>
@ -30,13 +30,13 @@
</button>
</div>
</bit-card>
<bit-password-settings
<tools-password-settings
class="tw-mt-6"
*ngIf="(credentialType$ | async) === 'password'"
[userId]="this.userId$ | async"
(onUpdated)="generate$.next()"
/>
<bit-passphrase-settings
<tools-passphrase-settings
class="tw-mt-6"
*ngIf="(credentialType$ | async) === 'passphrase'"
[userId]="this.userId$ | async"

View File

@ -13,7 +13,7 @@ import { PasswordSettingsComponent } from "./password-settings.component";
/** Options group for passwords */
@Component({
standalone: true,
selector: "bit-password-generator",
selector: "tools-password-generator",
templateUrl: "password-generator.component.html",
imports: [DependenciesModule, PasswordSettingsComponent, PassphraseSettingsComponent],
})

View File

@ -1,11 +1,11 @@
<bit-section>
<bit-section-header *ngIf="showHeader">
<h5 bitTypography="h5">{{ "options" | i18n }}</h5>
<h6 bitTypography="h6">{{ "options" | i18n }}</h6>
</bit-section-header>
<form class="box" [formGroup]="settings" class="tw-container">
<div class="tw-mb-4">
<bit-card>
<bit-form-field>
<bit-form-field disableMargin>
<bit-label>{{ "length" | i18n }}</bit-label>
<input
bitInput
@ -42,7 +42,7 @@
attr.aria-description="{{ 'numbersDescription' | i18n }}"
title="{{ 'numbersDescription' | i18n }}"
>
<input bitCheckbox type="checkbox" formControlName="numbers" />
<input bitCheckbox type="checkbox" formControlName="number" />
<bit-label>{{ "numbersLabel" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control
@ -76,10 +76,11 @@
/>
</bit-form-field>
</div>
<bit-form-control>
<bit-form-control [disableMargin]="!policyInEffect">
<input bitCheckbox type="checkbox" formControlName="avoidAmbiguous" />
<bit-label>{{ "avoidAmbiguous" | i18n }}</bit-label>
</bit-form-control>
<p *ngIf="policyInEffect" bitTypography="helper">{{ "generatorPolicyInEffect" | i18n }}</p>
</bit-card>
</div>
</form>

View File

@ -1,6 +1,6 @@
import { OnInit, Input, Output, EventEmitter, Component, OnDestroy } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, skip, takeUntil, Subject, map } from "rxjs";
import { BehaviorSubject, takeUntil, Subject, map, filter, tap, debounceTime, skip } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
@ -17,7 +17,7 @@ const Controls = Object.freeze({
length: "length",
uppercase: "uppercase",
lowercase: "lowercase",
numbers: "numbers",
number: "number",
special: "special",
minNumber: "minNumber",
minSpecial: "minSpecial",
@ -27,7 +27,7 @@ const Controls = Object.freeze({
/** Options group for passwords */
@Component({
standalone: true,
selector: "bit-password-settings",
selector: "tools-password-settings",
templateUrl: "password-settings.component.html",
imports: [DependenciesModule],
})
@ -54,6 +54,10 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
@Input()
showHeader: boolean = true;
/** Number of milliseconds to wait before accepting user input. */
@Input()
waitMs: number = 100;
/** Emits settings updates and completes if the settings become unavailable.
* @remarks this does not emit the initial settings. If you would like
* to receive live settings updates including the initial update,
@ -66,17 +70,34 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
[Controls.length]: [Generators.Password.settings.initial.length],
[Controls.uppercase]: [Generators.Password.settings.initial.uppercase],
[Controls.lowercase]: [Generators.Password.settings.initial.lowercase],
[Controls.numbers]: [Generators.Password.settings.initial.number],
[Controls.number]: [Generators.Password.settings.initial.number],
[Controls.special]: [Generators.Password.settings.initial.special],
[Controls.minNumber]: [Generators.Password.settings.initial.minNumber],
[Controls.minSpecial]: [Generators.Password.settings.initial.minSpecial],
[Controls.avoidAmbiguous]: [!Generators.Password.settings.initial.ambiguous],
});
private get numbers() {
return this.settings.get(Controls.number);
}
private get special() {
return this.settings.get(Controls.special);
}
private get minNumber() {
return this.settings.get(Controls.minNumber);
}
private get minSpecial() {
return this.settings.get(Controls.minSpecial);
}
async ngOnInit() {
const singleUserId$ = this.singleUserId$();
const settings = await this.generatorService.settings(Generators.Password, { singleUserId$ });
// bind settings to the UI
settings
.pipe(
map((settings) => {
@ -93,47 +114,41 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
this.settings.patchValue(s, { emitEvent: false });
});
// the first emission is the current value; subsequent emissions are updates
settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated);
///
// bind policy to the template
this.generatorService
.policy$(Generators.Password, { userId$: singleUserId$ })
.pipe(takeUntil(this.destroyed$))
.subscribe((policy) => {
.subscribe(({ constraints }) => {
this.settings
.get(Controls.length)
.setValidators(toValidators(Controls.length, Generators.Password, policy));
.setValidators(toValidators(Controls.length, Generators.Password, constraints));
this.settings
.get(Controls.minNumber)
.setValidators(toValidators(Controls.minNumber, Generators.Password, policy));
this.minNumber.setValidators(
toValidators(Controls.minNumber, Generators.Password, constraints),
);
this.settings
.get(Controls.minSpecial)
.setValidators(toValidators(Controls.minSpecial, Generators.Password, policy));
this.minSpecial.setValidators(
toValidators(Controls.minSpecial, Generators.Password, constraints),
);
// forward word boundaries to the template (can't do it through the rx form)
// FIXME: move the boundary logic fully into the policy evaluator
this.minLength = policy.length?.min ?? Generators.Password.settings.constraints.length.min;
this.maxLength = policy.length?.max ?? Generators.Password.settings.constraints.length.max;
this.minMinNumber =
policy.minNumber?.min ?? Generators.Password.settings.constraints.minNumber.min;
this.maxMinNumber =
policy.minNumber?.max ?? Generators.Password.settings.constraints.minNumber.max;
this.minMinSpecial =
policy.minSpecial?.min ?? Generators.Password.settings.constraints.minSpecial.min;
this.maxMinSpecial =
policy.minSpecial?.max ?? Generators.Password.settings.constraints.minSpecial.max;
this.minLength = constraints.length.min;
this.maxLength = constraints.length.max;
this.minMinNumber = constraints.minNumber.min;
this.maxMinNumber = constraints.minNumber.max;
this.minMinSpecial = constraints.minSpecial.min;
this.maxMinSpecial = constraints.minSpecial.max;
this.policyInEffect = constraints.policyInEffect;
const toggles = [
[Controls.length, policy.length.min < policy.length.max],
[Controls.uppercase, !policy.policy.useUppercase],
[Controls.lowercase, !policy.policy.useLowercase],
[Controls.numbers, !policy.policy.useNumbers],
[Controls.special, !policy.policy.useSpecial],
[Controls.minNumber, policy.minNumber.min < policy.minNumber.max],
[Controls.minSpecial, policy.minSpecial.min < policy.minSpecial.max],
[Controls.length, constraints.length.min < constraints.length.max],
[Controls.uppercase, !constraints.uppercase?.readonly],
[Controls.lowercase, !constraints.lowercase?.readonly],
[Controls.number, !constraints.number?.readonly],
[Controls.special, !constraints.special?.readonly],
[Controls.minNumber, constraints.minNumber.min < constraints.minNumber.max],
[Controls.minSpecial, constraints.minSpecial.min < constraints.minSpecial.max],
] as [keyof typeof Controls, boolean][];
for (const [control, enabled] of toggles) {
@ -141,9 +156,53 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
}
});
// cascade selections between checkboxes and spinboxes
// before the group saves their values
let lastMinNumber = 1;
this.numbers.valueChanges
.pipe(
filter((checked) => !(checked && this.minNumber.value > 0)),
map((checked) => (checked ? lastMinNumber : 0)),
takeUntil(this.destroyed$),
)
.subscribe((value) => this.minNumber.setValue(value, { emitEvent: false }));
this.minNumber.valueChanges
.pipe(
map((value) => [value, value > 0] as const),
tap(([value]) => (lastMinNumber = this.numbers.value ? value : lastMinNumber)),
takeUntil(this.destroyed$),
)
.subscribe(([, checked]) => this.numbers.setValue(checked, { emitEvent: false }));
let lastMinSpecial = 1;
this.special.valueChanges
.pipe(
filter((checked) => !(checked && this.minSpecial.value > 0)),
map((checked) => (checked ? lastMinSpecial : 0)),
takeUntil(this.destroyed$),
)
.subscribe((value) => this.minSpecial.setValue(value, { emitEvent: false }));
this.minSpecial.valueChanges
.pipe(
map((value) => [value, value > 0] as const),
tap(([value]) => (lastMinSpecial = this.special.value ? value : lastMinSpecial)),
takeUntil(this.destroyed$),
)
.subscribe(([, checked]) => this.special.setValue(checked, { emitEvent: false }));
// `onUpdated` depends on `settings` because the UserStateSubject is asynchronous;
// subscribing directly to `this.settings.valueChanges` introduces a race condition.
// skip the first emission because it's the initial value, not an update.
settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated);
// now that outputs are set up, connect inputs
this.settings.valueChanges
.pipe(
// debounce ensures rapid edits to a field, such as partial edits to a
// spinbox or rapid button clicks don't emit spurious generator updates
debounceTime(this.waitMs),
map((settings) => {
// interface is "avoid" while storage is "include"
const s: any = { ...settings };
@ -174,11 +233,14 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
/** attribute binding for minSpecial[max] */
protected maxMinSpecial: number;
/** display binding for enterprise policy notice */
protected policyInEffect: boolean;
private toggleEnabled(setting: keyof typeof Controls, enabled: boolean) {
if (enabled) {
this.settings.get(setting).enable();
this.settings.get(setting).enable({ emitEvent: false });
} else {
this.settings.get(setting).disable();
this.settings.get(setting).disable({ emitEvent: false });
}
}

View File

@ -1,5 +1,5 @@
import { ValidatorFn, Validators } from "@angular/forms";
import { map, pairwise, pipe, skipWhile, startWith, takeWhile } from "rxjs";
import { distinctUntilChanged, map, pairwise, pipe, skipWhile, startWith, takeWhile } from "rxjs";
import { AnyConstraint, Constraints } from "@bitwarden/common/tools/types";
import { UserId } from "@bitwarden/common/types/guid";
@ -13,6 +13,7 @@ export function completeOnAccountSwitch() {
pairwise(),
takeWhile(([prev, next]) => (prev ?? next) === next),
map(([_, id]) => id),
distinctUntilChanged(),
);
}

View File

@ -1,9 +1,10 @@
import { PasswordGenerationOptions } from "../types";
import { PasswordGenerationOptions, PasswordGeneratorSettings } from "../types";
import { DefaultPasswordBoundaries } from "./default-password-boundaries";
/** The default options for password generation. */
export const DefaultPasswordGenerationOptions: Partial<PasswordGenerationOptions> = Object.freeze({
export const DefaultPasswordGenerationOptions: Partial<PasswordGenerationOptions> &
PasswordGeneratorSettings = Object.freeze({
length: 14,
minLength: DefaultPasswordBoundaries.length.min,
ambiguous: true,

View File

@ -1,9 +1,11 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import {
DynamicPasswordPolicyConstraints,
passphraseLeastPrivilege,
passwordLeastPrivilege,
PassphraseGeneratorOptionsEvaluator,
PassphrasePolicyConstraints,
PasswordGeneratorOptionsEvaluator,
} from "../policies";
import {
@ -23,7 +25,7 @@ const PASSPHRASE = Object.freeze({
}),
combine: passphraseLeastPrivilege,
createEvaluator: (policy) => new PassphraseGeneratorOptionsEvaluator(policy),
createEvaluatorV2: (policy) => new PassphraseGeneratorOptionsEvaluator(policy),
toConstraints: (policy) => new PassphrasePolicyConstraints(policy),
} as PolicyConfiguration<PassphraseGeneratorPolicy, PassphraseGenerationOptions>);
const PASSWORD = Object.freeze({
@ -39,7 +41,7 @@ const PASSWORD = Object.freeze({
}),
combine: passwordLeastPrivilege,
createEvaluator: (policy) => new PasswordGeneratorOptionsEvaluator(policy),
createEvaluatorV2: (policy) => new PasswordGeneratorOptionsEvaluator(policy),
toConstraints: (policy) => new DynamicPasswordPolicyConstraints(policy),
} as PolicyConfiguration<PasswordGeneratorPolicy, PasswordGenerationOptions>);
/** Policy configurations */

View File

@ -0,0 +1,280 @@
import { Constraint } from "@bitwarden/common/tools/types";
import {
atLeast,
atLeastSum,
maybe,
maybeReadonly,
fitToBounds,
enforceConstant,
fitLength,
readonlyTrueWhen,
RequiresTrue,
} from "./constraints";
const SomeBooleanConstraint: Constraint<boolean> = Object.freeze({});
describe("password generator constraint utilities", () => {
describe("atLeast", () => {
it("creates a minimum constraint when constraint is undefined", () => {
const result = atLeast(1);
expect(result).toEqual({ min: 1 });
});
it("returns the constraint when minimum is undefined", () => {
const constraint = {};
const result = atLeast(undefined, constraint);
expect(result).toBe(constraint);
});
it("adds a minimum member to a constraint", () => {
const result = atLeast(1, {});
expect(result).toEqual({ min: 1 });
});
it("adjusts the minimum member of a constraint to the minimum value", () => {
const result = atLeast(2, { min: 1 });
expect(result).toEqual({ min: 2 });
});
it("adjusts the maximum member of a constraint to the minimum value", () => {
const result = atLeast(2, { min: 0, max: 1 });
expect(result).toEqual({ min: 2, max: 2 });
});
it("copies the constraint", () => {
const constraint = { min: 1, step: 1 };
const result = atLeast(1, constraint);
expect(result).not.toBe(constraint);
expect(result).toEqual({ min: 1, step: 1 });
});
});
describe("atLeastSum", () => {
it("creates a minimum constraint", () => {
const result = atLeastSum(undefined, []);
expect(result).toEqual({ min: 0 });
});
it("creates a minimum constraint that is the sum of the dependencies' minimums", () => {
const result = atLeastSum(undefined, [{ min: 1 }, { min: 1 }]);
expect(result).toEqual({ min: 2 });
});
it("adds a minimum member to a constraint", () => {
const result = atLeastSum({}, []);
expect(result).toEqual({ min: 0 });
});
it("adjusts the minimum member of a constraint to the minimum sum", () => {
const result = atLeastSum({ min: 0 }, [{ min: 1 }]);
expect(result).toEqual({ min: 1 });
});
it("adjusts the maximum member of a constraint to the minimum sum", () => {
const result = atLeastSum({ min: 0, max: 1 }, [{ min: 2 }]);
expect(result).toEqual({ min: 2, max: 2 });
});
it("copies the constraint", () => {
const constraint = { step: 1 };
const result = atLeastSum(constraint, []);
expect(result).not.toBe(constraint);
expect(result).toEqual({ min: 0, step: 1 });
});
});
describe("maybe", () => {
it("returns the constraint when it is enabled", () => {
const result = maybe(true, SomeBooleanConstraint);
expect(result).toBe(SomeBooleanConstraint);
});
it("returns undefined when the constraint is disabled", () => {
const result = maybe(false, SomeBooleanConstraint);
expect(result).toBeUndefined();
});
});
describe("maybeReadonly", () => {
it("returns the constraint when readonly is false", () => {
const result = maybeReadonly(false, SomeBooleanConstraint);
expect(result).toBe(SomeBooleanConstraint);
});
it("adds a readonly member when readonly is true", () => {
const result = maybeReadonly(true, SomeBooleanConstraint);
expect(result).toMatchObject({ readonly: true });
});
it("copies the constraint when readonly is true", () => {
const result = maybeReadonly(true, { requiredValue: true });
expect(result).not.toBe(SomeBooleanConstraint);
expect(result).toMatchObject({ readonly: true, requiredValue: true });
});
it("crates a readonly constraint when the input is undefined", () => {
const result = maybeReadonly(true);
expect(result).not.toBe(SomeBooleanConstraint);
expect(result).toEqual({ readonly: true });
});
});
describe("fitToBounds", () => {
it("returns the value when the constraint is undefined", () => {
const result = fitToBounds(1, undefined);
expect(result).toEqual(1);
});
it("applies the maximum bound", () => {
const result = fitToBounds(2, { max: 1 });
expect(result).toEqual(1);
});
it("applies the minimum bound", () => {
const result = fitToBounds(0, { min: 1 });
expect(result).toEqual(1);
});
it.each([[0], [1]])(
"returns 0 when value is undefined and 0 <= the maximum bound (= %p)",
(max) => {
const result = fitToBounds(undefined, { max });
expect(result).toEqual(0);
},
);
it.each([[0], [-1]])(
"returns 0 when value is undefined and 0 >= the minimum bound (= %p)",
(min) => {
const result = fitToBounds(undefined, { min });
expect(result).toEqual(0);
},
);
it("returns the maximum bound when value is undefined and 0 > the maximum bound", () => {
const result = fitToBounds(undefined, { max: -1 });
expect(result).toEqual(-1);
});
it("returns the minimum bound when value is undefined and 0 < the minimum bound", () => {
const result = fitToBounds(undefined, { min: 1 });
expect(result).toEqual(1);
});
});
describe("fitLength", () => {
it("returns the value when the constraint is undefined", () => {
const result = fitLength("someValue", undefined);
expect(result).toEqual("someValue");
});
it.each([[null], [undefined]])(
"returns an empty string when the value is nullish (= %p)",
(value: string) => {
const result = fitLength(value, {});
expect(result).toEqual("");
},
);
it("applies the maxLength bound", () => {
const result = fitLength("some value", { maxLength: 4 });
expect(result).toEqual("some");
});
it("applies the minLength bound", () => {
const result = fitLength("some", { minLength: 5 });
expect(result).toEqual("some ");
});
it("fills characters from the fillString", () => {
const result = fitLength("some", { minLength: 10 }, { fillString: " value" });
expect(result).toEqual("some value");
});
it("repeats characters from the fillString", () => {
const result = fitLength("i", { minLength: 3 }, { fillString: "+" });
expect(result).toEqual("i++");
});
});
describe("enforceConstant", () => {
it("returns the requiredValue member from a readonly constraint", () => {
const result = enforceConstant(false, { readonly: true, requiredValue: true });
expect(result).toBeTruthy();
});
it("returns undefined from a readonly constraint without a required value", () => {
const result = enforceConstant(false, { readonly: true });
expect(result).toBeUndefined();
});
it.each([[{}], [{ readonly: false }]])(
"returns value when the constraint is writable (= %p)",
(constraint) => {
const result = enforceConstant(false, constraint);
expect(result).toBeFalsy();
},
);
it("returns value when the constraint is undefined", () => {
const result = enforceConstant(false, undefined);
expect(result).toBeFalsy();
});
});
describe("readonlyTrueWhen", () => {
it.each([[false], [null], [undefined]])(
"returns undefined when enabled is falsy (= %p)",
(value) => {
const result = readonlyTrueWhen(value);
expect(result).toBeUndefined();
},
);
it("returns a readonly RequiresTrue when enabled is true", () => {
const result = readonlyTrueWhen(true);
expect(result).toMatchObject({ readonly: true });
expect(result).toMatchObject(RequiresTrue);
});
});
});

View File

@ -0,0 +1,164 @@
import { Constraint } from "@bitwarden/common/tools/types";
import { sum } from "../util";
const AtLeastOne: Constraint<number> = { min: 1 };
const RequiresTrue: Constraint<boolean> = { requiredValue: true };
/** Ensures the minimum and maximum bounds of a constraint are at least as large as the
* combined minimum bounds of `dependencies`.
* @param current the constraint extended by the combinator.
* @param dependencies the constraints summed to determine the bounds of `current`.
* @returns a copy of `current` with the new bounds applied.
*
*/
function atLeastSum(current: Constraint<number>, dependencies: Constraint<number>[]) {
// length must be at least as long as the required character set
const minConsistentLength = sum(...dependencies.map((c) => c?.min));
const minLength = Math.max(current?.min ?? 0, minConsistentLength);
const length = atLeast(minLength, current);
return length;
}
/** Extends a constraint with a readonly field.
* @param readonly Adds a readonly field when this is `true`.
* @param constraint the constraint extended by the combinator.
* @returns a copy of `constraint` with the readonly constraint applied as-needed.
*/
function maybeReadonly(readonly: boolean, constraint?: Constraint<boolean>): Constraint<boolean> {
if (!readonly) {
return constraint;
}
const result: Constraint<boolean> = Object.assign({}, constraint ?? {});
result.readonly = true;
return result;
}
/** Conditionally enables a constraint.
* @param enabled the condition to evaluate
* @param constraint the condition to conditionally enable
* @returns `constraint` when `enabled` is true. Otherwise returns `undefined.
*/
function maybe<T>(enabled: boolean, constraint: Constraint<T>): Constraint<T> {
return enabled ? constraint : undefined;
}
// copies `constraint`; ensures both bounds >= value
/** Ensures the boundaries of a constraint are at least equal to the minimum.
* @param minimum the lower bound of the constraint. When this is `undefined` or `null`,
* the method returns `constraint`.
* @param constraint the constraint to evaluate. When this is `undefined` or `null`,
* the method creates a new constraint.
* @returns a copy of `constraint`. When `minimum` has a value, the returned constraint
* always includes a minimum bound. When `constraint` has a maximum defined, both
* its minimum and maximum are checked against `minimum`.
*/
function atLeast(minimum: number, constraint?: Constraint<number>): Constraint<number> {
if (minimum === undefined || minimum === null) {
return constraint;
}
const atLeast = { ...(constraint ?? {}) };
atLeast.min = Math.max(atLeast.min ?? -Infinity, minimum);
if ("max" in atLeast) {
atLeast.max = Math.max(atLeast.max, minimum);
}
return atLeast;
}
/** Ensures a value falls within the minimum and maximum boundaries of a constraint.
* @param value the value to check. Nullish values are coerced to 0.
* @param constraint the constraint to evaluate against.
* @returns If the value is below the minimum constraint, the minimum bound is
* returned. If the value is above the maximum constraint, the maximum bound is
* returned. Otherwise, the value is returned.
*/
function fitToBounds(value: number, constraint: Constraint<number>) {
if (!constraint) {
return value;
}
const { min, max } = constraint;
const withUpperBound = Math.min(value ?? 0, max ?? Infinity);
const withLowerBound = Math.max(withUpperBound, min ?? -Infinity);
return withLowerBound;
}
/** Fits the length of a string within the minimum and maximum length boundaries
* of a constraint.
* @param value the value to check. Nullish values are coerced to the empty string.
* @param constraint the constraint to evaluate against.
* @param options.fillString a string to fill values from. Defaults to a space.
* When fillString contains multiple characters, each is filled in order. The
* fill string repeats when it gets to the end of the string and there are
* more characters to fill.
* @returns If the value is below the required length, returns a copy padded
* by the fillString. If the value is above the required length, returns a copy
* padded to the maximum length.
* */
function fitLength(
value: string,
constraint: Constraint<string>,
options?: { fillString?: string },
) {
if (!constraint) {
return value;
}
const { minLength, maxLength } = constraint;
const { fillString } = options ?? { fillString: " " };
const trimmed = (value ?? "").slice(0, maxLength ?? Infinity);
const result = trimmed.padEnd(minLength ?? trimmed.length, fillString);
return result;
}
/** Enforces a readonly field has a required value.
* @param value the value to check.
* @param constraint the constraint to evaluate against.
* @returns If the constraint's readonly field is `true`, returns the
* constraint's required value or `undefined` if none is specified.
* Otherwise returns the value.
* @remarks This method can be used to ensure a conditionally-calculated
* field becomes undefined. Simply specify `readonly` without a `requiredValue`
* then use `??` to perform the calculation.
*/
function enforceConstant(value: boolean, constraint: Constraint<boolean>) {
if (constraint?.readonly) {
return constraint.requiredValue;
} else {
return value;
}
}
/** Conditionally create a readonly true value.
* @param enabled When true, create the value.
* @returns When enabled is true, a readonly constraint with a constant value
* of `true`. Otherwise returns `undefined`.
*/
function readonlyTrueWhen(enabled: boolean) {
const readonlyValue = maybeReadonly(enabled, RequiresTrue);
const maybeReadonlyValue = maybe(enabled, readonlyValue);
return maybeReadonlyValue;
}
export {
atLeast,
atLeastSum,
maybe,
maybeReadonly,
fitToBounds,
enforceConstant,
readonlyTrueWhen,
fitLength,
AtLeastOne,
RequiresTrue,
};

View File

@ -0,0 +1,262 @@
import { DefaultPasswordBoundaries, DefaultPasswordGenerationOptions, Policies } from "../data";
import { AtLeastOne } from "./constraints";
import { DynamicPasswordPolicyConstraints } from "./dynamic-password-policy-constraints";
describe("DynamicPasswordPolicyConstraints", () => {
describe("constructor", () => {
it("uses default boundaries when the policy is disabled", () => {
const { constraints } = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
expect(constraints.policyInEffect).toBeFalsy();
expect(constraints.length).toEqual(DefaultPasswordBoundaries.length);
expect(constraints.lowercase).toBeUndefined();
expect(constraints.uppercase).toBeUndefined();
expect(constraints.number).toBeUndefined();
expect(constraints.special).toBeUndefined();
expect(constraints.minLowercase).toBeUndefined();
expect(constraints.minUppercase).toBeUndefined();
expect(constraints.minNumber).toEqual(DefaultPasswordBoundaries.minDigits);
expect(constraints.minSpecial).toEqual(DefaultPasswordBoundaries.minSpecialCharacters);
});
it("1 <= minLowercase when the policy requires lowercase", () => {
const policy = { ...Policies.Password.disabledValue, useLowercase: true };
const { constraints } = new DynamicPasswordPolicyConstraints(policy);
expect(constraints.policyInEffect).toBeTruthy();
expect(constraints.lowercase.readonly).toEqual(true);
expect(constraints.lowercase.requiredValue).toEqual(true);
expect(constraints.minLowercase).toEqual({ min: 1 });
});
it("1 <= minUppercase when the policy requires uppercase", () => {
const policy = { ...Policies.Password.disabledValue, useUppercase: true };
const { constraints } = new DynamicPasswordPolicyConstraints(policy);
expect(constraints.policyInEffect).toBeTruthy();
expect(constraints.uppercase.readonly).toEqual(true);
expect(constraints.uppercase.requiredValue).toEqual(true);
expect(constraints.minUppercase).toEqual({ min: 1 });
});
it("1 <= minNumber <= 9 when the policy requires a number", () => {
const policy = { ...Policies.Password.disabledValue, useNumbers: true };
const { constraints } = new DynamicPasswordPolicyConstraints(policy);
expect(constraints.policyInEffect).toBeTruthy();
expect(constraints.number.readonly).toEqual(true);
expect(constraints.number.requiredValue).toEqual(true);
expect(constraints.minNumber).toEqual({ min: 1, max: 9 });
});
it("1 <= minSpecial <= 9 when the policy requires a special character", () => {
const policy = { ...Policies.Password.disabledValue, useSpecial: true };
const { constraints } = new DynamicPasswordPolicyConstraints(policy);
expect(constraints.policyInEffect).toBeTruthy();
expect(constraints.special.readonly).toEqual(true);
expect(constraints.special.requiredValue).toEqual(true);
expect(constraints.minSpecial).toEqual({ min: 1, max: 9 });
});
it("numberCount <= minNumber <= 9 when the policy requires numberCount", () => {
const policy = { ...Policies.Password.disabledValue, useNumbers: true, numberCount: 2 };
const { constraints } = new DynamicPasswordPolicyConstraints(policy);
expect(constraints.policyInEffect).toBeTruthy();
expect(constraints.number.readonly).toEqual(true);
expect(constraints.number.requiredValue).toEqual(true);
expect(constraints.minNumber).toEqual({ min: 2, max: 9 });
});
it("specialCount <= minSpecial <= 9 when the policy requires specialCount", () => {
const policy = { ...Policies.Password.disabledValue, useSpecial: true, specialCount: 2 };
const { constraints } = new DynamicPasswordPolicyConstraints(policy);
expect(constraints.policyInEffect).toBeTruthy();
expect(constraints.special.readonly).toEqual(true);
expect(constraints.special.requiredValue).toEqual(true);
expect(constraints.minSpecial).toEqual({ min: 2, max: 9 });
});
it("uses the policy's minimum length when the policy defines one", () => {
const policy = { ...Policies.Password.disabledValue, minLength: 10 };
const { constraints } = new DynamicPasswordPolicyConstraints(policy);
expect(constraints.policyInEffect).toBeTruthy();
expect(constraints.length).toEqual({ min: 10, max: 128 });
});
it("overrides the minimum length when it is less than the sum of minimums", () => {
const policy = {
...Policies.Password.disabledValue,
useUppercase: true,
useLowercase: true,
useNumbers: true,
numberCount: 5,
useSpecial: true,
specialCount: 5,
};
const { constraints } = new DynamicPasswordPolicyConstraints(policy);
// lower + upper + number + special = 1 + 1 + 5 + 5 = 12
expect(constraints.length).toEqual({ min: 12, max: 128 });
});
});
describe("calibrate", () => {
it("copies the boolean constraints into the calibration", () => {
const dynamic = new DynamicPasswordPolicyConstraints({
...Policies.Password.disabledValue,
useUppercase: true,
useLowercase: true,
useNumbers: true,
useSpecial: true,
});
const calibrated = dynamic.calibrate(DefaultPasswordGenerationOptions);
expect(calibrated.constraints.uppercase).toEqual(dynamic.constraints.uppercase);
expect(calibrated.constraints.lowercase).toEqual(dynamic.constraints.lowercase);
expect(calibrated.constraints.number).toEqual(dynamic.constraints.number);
expect(calibrated.constraints.special).toEqual(dynamic.constraints.special);
});
it.each([[true], [false], [undefined]])(
"outputs at least 1 constraint when the state's lowercase flag is true and useLowercase is %p",
(useLowercase) => {
const dynamic = new DynamicPasswordPolicyConstraints({
...Policies.Password.disabledValue,
useLowercase,
});
const state = {
...DefaultPasswordGenerationOptions,
lowercase: true,
};
const calibrated = dynamic.calibrate(state);
expect(calibrated.constraints.minLowercase).toEqual(AtLeastOne);
},
);
it("outputs the `minLowercase` constraint when the state's lowercase flag is true and policy is disabled", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const state = {
...DefaultPasswordGenerationOptions,
lowercase: true,
};
const calibrated = dynamic.calibrate(state);
expect(calibrated.constraints.minLowercase).toEqual(AtLeastOne);
});
it("disables the minLowercase constraint when the state's lowercase flag is false", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const state = {
...DefaultPasswordGenerationOptions,
lowercase: false,
};
const calibrated = dynamic.calibrate(state);
expect(calibrated.constraints.minLowercase).toBeUndefined();
});
it.each([[true], [false], [undefined]])(
"outputs at least 1 constraint when the state's uppercase flag is true and useUppercase is %p",
(useUppercase) => {
const dynamic = new DynamicPasswordPolicyConstraints({
...Policies.Password.disabledValue,
useUppercase,
});
const state = {
...DefaultPasswordGenerationOptions,
uppercase: true,
};
const calibrated = dynamic.calibrate(state);
expect(calibrated.constraints.minUppercase).toEqual(AtLeastOne);
},
);
it("disables the minUppercase constraint when the state's uppercase flag is false", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const state = {
...DefaultPasswordGenerationOptions,
uppercase: false,
};
const calibrated = dynamic.calibrate(state);
expect(calibrated.constraints.minUppercase).toBeUndefined();
});
it("outputs the minNumber constraint when the state's number flag is true", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const state = {
...DefaultPasswordGenerationOptions,
number: true,
};
const calibrated = dynamic.calibrate(state);
expect(calibrated.constraints.minNumber).toEqual(dynamic.constraints.minNumber);
});
it("disables the minNumber constraint when the state's number flag is false", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const state = {
...DefaultPasswordGenerationOptions,
number: false,
};
const calibrated = dynamic.calibrate(state);
expect(calibrated.constraints.minNumber).toBeUndefined();
});
it("outputs the minSpecial constraint when the state's special flag is true", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const state = {
...DefaultPasswordGenerationOptions,
special: true,
};
const calibrated = dynamic.calibrate(state);
expect(calibrated.constraints.minSpecial).toEqual(dynamic.constraints.minSpecial);
});
it("disables the minSpecial constraint when the state's special flag is false", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const state = {
...DefaultPasswordGenerationOptions,
special: false,
};
const calibrated = dynamic.calibrate(state);
expect(calibrated.constraints.minSpecial).toBeUndefined();
});
it("copies the minimum length constraint", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const calibrated = dynamic.calibrate(DefaultPasswordGenerationOptions);
expect(calibrated.constraints.minSpecial).toBeUndefined();
});
it("overrides the minimum length constraint when it is less than the sum of the state's minimums", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const calibrated = dynamic.calibrate(DefaultPasswordGenerationOptions);
expect(calibrated.constraints.minSpecial).toBeUndefined();
});
});
});

View File

@ -0,0 +1,100 @@
import {
DynamicStateConstraints,
PolicyConstraints,
StateConstraints,
} from "@bitwarden/common/tools/types";
import { DefaultPasswordBoundaries } from "../data";
import { PasswordGeneratorPolicy, PasswordGeneratorSettings } from "../types";
import { atLeast, atLeastSum, maybe, readonlyTrueWhen, AtLeastOne } from "./constraints";
import { PasswordPolicyConstraints } from "./password-policy-constraints";
/** Creates state constraints by blending policy and password settings. */
export class DynamicPasswordPolicyConstraints
implements DynamicStateConstraints<PasswordGeneratorSettings>
{
/** Instantiates the object.
* @param policy the password policy to enforce. This cannot be
* `null` or `undefined`.
*/
constructor(policy: PasswordGeneratorPolicy) {
const minLowercase = maybe(policy.useLowercase, AtLeastOne);
const minUppercase = maybe(policy.useUppercase, AtLeastOne);
const minNumber = atLeast(
policy.numberCount || (policy.useNumbers && AtLeastOne.min),
DefaultPasswordBoundaries.minDigits,
);
const minSpecial = atLeast(
policy.specialCount || (policy.useSpecial && AtLeastOne.min),
DefaultPasswordBoundaries.minSpecialCharacters,
);
const baseLength = atLeast(policy.minLength, DefaultPasswordBoundaries.length);
const subLengths = [minLowercase, minUppercase, minNumber, minSpecial];
const length = atLeastSum(baseLength, subLengths);
this.constraints = Object.freeze({
policyInEffect: policyInEffect(policy),
lowercase: readonlyTrueWhen(policy.useLowercase),
uppercase: readonlyTrueWhen(policy.useUppercase),
number: readonlyTrueWhen(policy.useNumbers),
special: readonlyTrueWhen(policy.useSpecial),
length,
minLowercase,
minUppercase,
minNumber,
minSpecial,
});
}
/** Constraints derived from the policy and application-defined defaults;
* @remarks these limits are absolute and should be transmitted to the UI
*/
readonly constraints: PolicyConstraints<PasswordGeneratorSettings>;
calibrate(state: PasswordGeneratorSettings): StateConstraints<PasswordGeneratorSettings> {
// decide which constraints are active
const lowercase = state.lowercase || this.constraints.lowercase?.requiredValue || false;
const uppercase = state.uppercase || this.constraints.uppercase?.requiredValue || false;
const number = state.number || this.constraints.number?.requiredValue || false;
const special = state.special || this.constraints.special?.requiredValue || false;
// minimum constraints cannot `atLeast(state...) because doing so would force
// the constrained value to only increase
const constraints: PolicyConstraints<PasswordGeneratorSettings> = {
...this.constraints,
minLowercase: maybe<number>(lowercase, this.constraints.minLowercase ?? AtLeastOne),
minUppercase: maybe<number>(uppercase, this.constraints.minUppercase ?? AtLeastOne),
minNumber: maybe<number>(number, this.constraints.minNumber),
minSpecial: maybe<number>(special, this.constraints.minSpecial),
};
// lower bound of length must always at least fit its sub-lengths
constraints.length = atLeastSum(this.constraints.length, [
atLeast(state.minNumber, constraints.minNumber),
atLeast(state.minSpecial, constraints.minSpecial),
atLeast(state.minLowercase, constraints.minLowercase),
atLeast(state.minUppercase, constraints.minUppercase),
]);
const stateConstraints = new PasswordPolicyConstraints(constraints);
return stateConstraints;
}
}
function policyInEffect(policy: PasswordGeneratorPolicy): boolean {
const policies = [
policy.useUppercase,
policy.useLowercase,
policy.useNumbers,
policy.useSpecial,
policy.minLength > DefaultPasswordBoundaries.length.min,
policy.numberCount > DefaultPasswordBoundaries.minDigits.min,
policy.specialCount > DefaultPasswordBoundaries.minSpecialCharacters.min,
];
return policies.includes(true);
}

View File

@ -1,5 +1,7 @@
export { DefaultPolicyEvaluator } from "./default-policy-evaluator";
export { DynamicPasswordPolicyConstraints } from "./dynamic-password-policy-constraints";
export { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator";
export { PassphrasePolicyConstraints } from "./passphrase-policy-constraints";
export { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator";
export { passphraseLeastPrivilege } from "./passphrase-least-privilege";
export { passwordLeastPrivilege } from "./password-least-privilege";

View File

@ -0,0 +1,134 @@
import { DefaultPassphraseBoundaries, Policies } from "../data";
import { PassphrasePolicyConstraints } from "./passphrase-policy-constraints";
const SomeSettings = {
capitalize: false,
includeNumber: false,
numWords: 3,
wordSeparator: "-",
};
describe("PassphrasePolicyConstraints", () => {
describe("constructor", () => {
it("uses default boundaries when the policy is disabled", () => {
const { constraints } = new PassphrasePolicyConstraints(Policies.Passphrase.disabledValue);
expect(constraints.policyInEffect).toBeFalsy();
expect(constraints.capitalize).toBeUndefined();
expect(constraints.includeNumber).toBeUndefined();
expect(constraints.numWords).toEqual(DefaultPassphraseBoundaries.numWords);
});
it("requires capitalization when the policy requires capitalization", () => {
const { constraints } = new PassphrasePolicyConstraints({
...Policies.Passphrase.disabledValue,
capitalize: true,
});
expect(constraints.policyInEffect).toBeTruthy();
expect(constraints.capitalize).toMatchObject({ readonly: true, requiredValue: true });
});
it("requires a number when the policy requires a number", () => {
const { constraints } = new PassphrasePolicyConstraints({
...Policies.Passphrase.disabledValue,
includeNumber: true,
});
expect(constraints.policyInEffect).toBeTruthy();
expect(constraints.includeNumber).toMatchObject({ readonly: true, requiredValue: true });
});
it("minNumberWords <= numWords.min when the policy requires numberCount", () => {
const { constraints } = new PassphrasePolicyConstraints({
...Policies.Passphrase.disabledValue,
minNumberWords: 10,
});
expect(constraints.policyInEffect).toBeTruthy();
expect(constraints.numWords).toMatchObject({
min: 10,
max: DefaultPassphraseBoundaries.numWords.max,
});
});
});
describe("adjust", () => {
it("allows an empty word separator", () => {
const policy = new PassphrasePolicyConstraints(Policies.Passphrase.disabledValue);
const { wordSeparator } = policy.adjust({ ...SomeSettings, wordSeparator: "" });
expect(wordSeparator).toEqual("");
});
it("takes only the first character of wordSeparator", () => {
const policy = new PassphrasePolicyConstraints(Policies.Passphrase.disabledValue);
const { wordSeparator } = policy.adjust({ ...SomeSettings, wordSeparator: "?." });
expect(wordSeparator).toEqual("?");
});
it.each([
[1, 3],
[21, 20],
])("fits numWords (=%p) within the default bounds (3 <= %p <= 20)", (value, expected) => {
const policy = new PassphrasePolicyConstraints(Policies.Passphrase.disabledValue);
const { numWords } = policy.adjust({ ...SomeSettings, numWords: value });
expect(numWords).toEqual(expected);
});
it.each([
[1, 4, 4],
[21, 20, 20],
])(
"fits numWords (=%p) within the policy bounds (%p <= %p <= 20)",
(value, minNumberWords, expected) => {
const policy = new PassphrasePolicyConstraints({
...Policies.Passphrase.disabledValue,
minNumberWords,
});
const { numWords } = policy.adjust({ ...SomeSettings, numWords: value });
expect(numWords).toEqual(expected);
},
);
it("sets capitalize to true when the policy requires it", () => {
const policy = new PassphrasePolicyConstraints({
...Policies.Passphrase.disabledValue,
capitalize: true,
});
const { capitalize } = policy.adjust({ ...SomeSettings, capitalize: false });
expect(capitalize).toBeTruthy();
});
it("sets includeNumber to true when the policy requires it", () => {
const policy = new PassphrasePolicyConstraints({
...Policies.Passphrase.disabledValue,
includeNumber: true,
});
const { includeNumber } = policy.adjust({ ...SomeSettings, capitalize: false });
expect(includeNumber).toBeTruthy();
});
});
describe("fix", () => {
it("returns its input", () => {
const policy = new PassphrasePolicyConstraints(Policies.Passphrase.disabledValue);
const result = policy.fix(SomeSettings);
expect(result).toBe(SomeSettings);
});
});
});

View File

@ -0,0 +1,51 @@
import { PolicyConstraints, StateConstraints } from "@bitwarden/common/tools/types";
import { DefaultPassphraseBoundaries, DefaultPassphraseGenerationOptions } from "../data";
import { PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types";
import { atLeast, enforceConstant, fitLength, fitToBounds, readonlyTrueWhen } from "./constraints";
export class PassphrasePolicyConstraints implements StateConstraints<PassphraseGenerationOptions> {
/** Creates a passphrase policy constraints
* @param policy the password policy to enforce. This cannot be
* `null` or `undefined`.
*/
constructor(readonly policy: PassphraseGeneratorPolicy) {
this.constraints = {
policyInEffect: policyInEffect(policy),
wordSeparator: { minLength: 0, maxLength: 1 },
capitalize: readonlyTrueWhen(policy.capitalize),
includeNumber: readonlyTrueWhen(policy.includeNumber),
numWords: atLeast(policy.minNumberWords, DefaultPassphraseBoundaries.numWords),
};
}
constraints: Readonly<PolicyConstraints<PassphraseGenerationOptions>>;
adjust(state: PassphraseGenerationOptions): PassphraseGenerationOptions {
const result: PassphraseGenerationOptions = {
wordSeparator: fitLength(state.wordSeparator, this.constraints.wordSeparator, {
fillString: DefaultPassphraseGenerationOptions.wordSeparator,
}),
capitalize: enforceConstant(state.capitalize, this.constraints.capitalize),
includeNumber: enforceConstant(state.includeNumber, this.constraints.includeNumber),
numWords: fitToBounds(state.numWords, this.constraints.numWords),
};
return result;
}
fix(state: PassphraseGenerationOptions): PassphraseGenerationOptions {
return state;
}
}
function policyInEffect(policy: PassphraseGeneratorPolicy): boolean {
const policies = [
policy.capitalize,
policy.includeNumber,
policy.minNumberWords > DefaultPassphraseBoundaries.numWords.min,
];
return policies.includes(true);
}

View File

@ -0,0 +1,130 @@
import { PasswordGeneratorSettings } from "../types";
import { PasswordPolicyConstraints } from "./password-policy-constraints";
const EmptyState = {
length: 0,
ambiguous: false,
lowercase: false,
uppercase: false,
number: false,
special: false,
minUppercase: 0,
minLowercase: 0,
minNumber: 0,
minSpecial: 0,
};
describe("PasswordPolicyConstraints", () => {
describe("adjust", () => {
it("returns its input when the constraints are empty", () => {
const constraint = new PasswordPolicyConstraints({});
const expected = {
length: -1,
ambiguous: true,
lowercase: true,
uppercase: true,
number: true,
special: true,
minUppercase: -1,
minLowercase: -1,
minNumber: -1,
minSpecial: -1,
};
const result = constraint.adjust(expected);
expect(result).toEqual(expected);
});
it.each([
["length", 0, 1],
["length", 1, 1],
["length", 2, 2],
["length", 3, 2],
["minLowercase", 0, 1],
["minLowercase", 1, 1],
["minLowercase", 2, 2],
["minLowercase", 3, 2],
["minUppercase", 0, 1],
["minUppercase", 1, 1],
["minUppercase", 2, 2],
["minUppercase", 3, 2],
["minNumber", 0, 1],
["minNumber", 1, 1],
["minNumber", 2, 2],
["minNumber", 3, 2],
["minSpecial", 0, 1],
["minSpecial", 1, 1],
["minSpecial", 2, 2],
["minSpecial", 3, 2],
] as [keyof PasswordGeneratorSettings, number, number][])(
`fits %s (= %p) within the bounds (1 <= %p <= 2)`,
(property, input, expected) => {
const constraint = new PasswordPolicyConstraints({ [property]: { min: 1, max: 2 } });
const state = { ...EmptyState, [property]: input };
const result = constraint.adjust(state);
expect(result).toMatchObject({ [property]: expected });
},
);
it.each([["lowercase"], ["uppercase"], ["number"], ["special"]] as [
keyof PasswordGeneratorSettings,
][])("returns state.%s when the matching readonly constraint is writable", (property) => {
const constraint = new PasswordPolicyConstraints({ [property]: { readonly: false } });
const state = { ...EmptyState, [property]: true };
const result = constraint.adjust(state);
expect(result).toEqual({ ...EmptyState, [property]: true });
});
it("sets `lowercase` and `uppercase` to `true` when no flags are defined", () => {
const constraint = new PasswordPolicyConstraints({});
const result = constraint.adjust(EmptyState);
expect(result).toMatchObject({ lowercase: true, uppercase: true });
});
it.each([["number"], ["special"]] as [keyof PasswordGeneratorSettings][])(
"returns a consistent state.%s = undefined when the matching readonly constraint is active without a required value",
(property) => {
const constraint = new PasswordPolicyConstraints({ [property]: { readonly: true } });
const state = {
...EmptyState,
[property]: true,
};
const result = constraint.adjust(state);
expect(result).toMatchObject({ [property]: false });
},
);
it.each([["number"], ["special"]] as [keyof PasswordGeneratorSettings][])(
"returns state.%s = requiredValue when matching constraint is active with a required value",
(property) => {
const constraint = new PasswordPolicyConstraints({
[property]: { readonly: true, requiredValue: false },
});
const state = { ...EmptyState, [property]: true };
const result = constraint.adjust(state);
expect(result).toMatchObject({ [property]: false });
},
);
});
describe("fix", () => {
it("returns its input", () => {
const policy = new PasswordPolicyConstraints({});
const result = policy.fix(EmptyState);
expect(result).toBe(EmptyState);
});
});
});

View File

@ -0,0 +1,50 @@
import { PolicyConstraints, StateConstraints } from "@bitwarden/common/tools/types";
import { DefaultPasswordGenerationOptions } from "../data";
import { PasswordGeneratorSettings } from "../types";
import { fitToBounds, enforceConstant } from "./constraints";
export class PasswordPolicyConstraints implements StateConstraints<PasswordGeneratorSettings> {
/** Creates a password policy constraints
* @param constraints Constraints derived from the policy and application-defined defaults
*/
constructor(readonly constraints: PolicyConstraints<PasswordGeneratorSettings>) {}
adjust(state: PasswordGeneratorSettings): PasswordGeneratorSettings {
// constrain values
const result: PasswordGeneratorSettings = {
...(state ?? DefaultPasswordGenerationOptions),
length: fitToBounds(state.length, this.constraints.length),
lowercase: enforceConstant(state.lowercase, this.constraints.lowercase),
uppercase: enforceConstant(state.uppercase, this.constraints.uppercase),
number: enforceConstant(state.number, this.constraints.number),
special: enforceConstant(state.special, this.constraints.special),
minLowercase: fitToBounds(state.minLowercase, this.constraints.minLowercase),
minUppercase: fitToBounds(state.minUppercase, this.constraints.minUppercase),
minNumber: fitToBounds(state.minNumber, this.constraints.minNumber),
minSpecial: fitToBounds(state.minSpecial, this.constraints.minSpecial),
};
// ensure include flags are consistent with the constrained values
result.lowercase ||= state.minLowercase > 0;
result.uppercase ||= state.minUppercase > 0;
result.number ||= state.minNumber > 0;
result.special ||= state.minSpecial > 0;
// when all flags are disabled, enable a few
const anyEnabled = [result.lowercase, result.uppercase, result.number, result.special].some(
(flag) => flag,
);
if (!anyEnabled) {
result.lowercase = true;
result.uppercase = true;
}
return result;
}
fix(state: PasswordGeneratorSettings): PasswordGeneratorSettings {
return state;
}
}

View File

@ -18,16 +18,16 @@ export function mapPolicyToEvaluator<Policy, Evaluator>(
);
}
/** Maps an administrative console policy to a policy evaluator using the provided configuration.
* @param configuration the configuration that constructs the evaluator.
/** Maps an administrative console policy to constraints using the provided configuration.
* @param configuration the configuration that constructs the constraints.
*/
export function mapPolicyToEvaluatorV2<Policy, Evaluator>(
export function mapPolicyToConstraints<Policy, Evaluator>(
configuration: PolicyConfiguration<Policy, Evaluator>,
) {
return pipe(
reduceCollection(configuration.combine, configuration.disabledValue),
distinctIfShallowMatch(),
map(configuration.createEvaluatorV2),
map(configuration.toConstraints),
);
}

View File

@ -5,7 +5,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { Constraints } from "@bitwarden/common/tools/types";
import { StateConstraints } from "@bitwarden/common/tools/types";
import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid";
import {
@ -14,8 +14,12 @@ import {
awaitAsync,
ObservableTracker,
} from "../../../../../common/spec";
import { PolicyEvaluator, Randomizer } from "../abstractions";
import { CredentialGeneratorConfiguration, GeneratedCredential } from "../types";
import { Randomizer } from "../abstractions";
import {
CredentialGeneratorConfiguration,
GeneratedCredential,
GeneratorConstraints,
} from "../types";
import { CredentialGeneratorService } from "./credential-generator.service";
@ -72,18 +76,37 @@ const SomeConfiguration: CredentialGeneratorConfiguration<SomeSettings, SomePoli
createEvaluator: () => {
throw new Error("this should never be called");
},
createEvaluatorV2: (policy) => {
return {
foo: {},
policy,
policyInEffect: policy.fooPolicy,
applyPolicy: (settings) => {
return policy.fooPolicy ? { foo: `apply(${settings.foo})` } : settings;
},
sanitize: (settings) => {
return policy.fooPolicy ? { foo: `sanitize(${settings.foo})` } : settings;
},
} as PolicyEvaluator<SomePolicy, SomeSettings> & Constraints<SomeSettings>;
toConstraints: (policy) => {
if (policy.fooPolicy) {
return {
constraints: {
policyInEffect: true,
},
calibrate(state: SomeSettings) {
return {
constraints: {},
adjust(state: SomeSettings) {
return { foo: `adjusted(${state.foo})` };
},
fix(state: SomeSettings) {
return { foo: `fixed(${state.foo})` };
},
} satisfies StateConstraints<SomeSettings>;
},
} satisfies GeneratorConstraints<SomeSettings>;
} else {
return {
constraints: {
policyInEffect: false,
},
adjust(state: SomeSettings) {
return state;
},
fix(state: SomeSettings) {
return state;
},
} satisfies GeneratorConstraints<SomeSettings>;
}
},
},
};
@ -378,7 +401,7 @@ describe("CredentialGeneratorService", () => {
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
expect(result).toEqual({ foo: "sanitize(apply(value))" });
expect(result).toEqual({ foo: "adjusted(value)" });
});
it("follows changes to the active user", async () => {
@ -525,17 +548,16 @@ describe("CredentialGeneratorService", () => {
});
describe("policy$", () => {
it("creates a disabled policy evaluator when there is no policy", async () => {
it("creates constraints without policy in effect when there is no policy", async () => {
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId$ = new BehaviorSubject(SomeUser).asObservable();
const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ }));
expect(result.policy).toEqual(SomeConfiguration.policy.disabledValue);
expect(result.policyInEffect).toBeFalsy();
expect(result.constraints.policyInEffect).toBeFalsy();
});
it("creates an active policy evaluator when there is a policy", async () => {
it("creates constraints with policy in effect when there is a policy", async () => {
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId$ = new BehaviorSubject(SomeUser).asObservable();
const policy$ = new BehaviorSubject([somePolicy]);
@ -543,8 +565,7 @@ describe("CredentialGeneratorService", () => {
const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ }));
expect(result.policy).toEqual({ fooPolicy: true });
expect(result.policyInEffect).toBeTruthy();
expect(result.constraints.policyInEffect).toBeTruthy();
});
it("follows policy emissions", async () => {
@ -553,7 +574,7 @@ describe("CredentialGeneratorService", () => {
const userId$ = userId.asObservable();
const somePolicySubject = new BehaviorSubject([somePolicy]);
policyService.getAll$.mockReturnValueOnce(somePolicySubject.asObservable());
const emissions: any = [];
const emissions: GeneratorConstraints<SomeSettings>[] = [];
const sub = generator
.policy$(SomeConfiguration, { userId$ })
.subscribe((policy) => emissions.push(policy));
@ -564,10 +585,8 @@ describe("CredentialGeneratorService", () => {
sub.unsubscribe();
const [someResult, anotherResult] = emissions;
expect(someResult.policy).toEqual({ fooPolicy: true });
expect(someResult.policyInEffect).toBeTruthy();
expect(anotherResult.policy).toEqual(SomeConfiguration.policy.disabledValue);
expect(anotherResult.policyInEffect).toBeFalsy();
expect(someResult.constraints.policyInEffect).toBeTruthy();
expect(anotherResult.constraints.policyInEffect).toBeFalsy();
});
it("follows user emissions", async () => {
@ -577,7 +596,7 @@ describe("CredentialGeneratorService", () => {
const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable();
const anotherPolicy$ = new BehaviorSubject([]).asObservable();
policyService.getAll$.mockReturnValueOnce(somePolicy$).mockReturnValueOnce(anotherPolicy$);
const emissions: any = [];
const emissions: GeneratorConstraints<SomeSettings>[] = [];
const sub = generator
.policy$(SomeConfiguration, { userId$ })
.subscribe((policy) => emissions.push(policy));
@ -588,10 +607,8 @@ describe("CredentialGeneratorService", () => {
sub.unsubscribe();
const [someResult, anotherResult] = emissions;
expect(someResult.policy).toEqual({ fooPolicy: true });
expect(someResult.policyInEffect).toBeTruthy();
expect(anotherResult.policy).toEqual(SomeConfiguration.policy.disabledValue);
expect(anotherResult.policyInEffect).toBeFalsy();
expect(someResult.constraints.policyInEffect).toBeTruthy();
expect(anotherResult.constraints.policyInEffect).toBeFalsy();
});
it("errors when the user errors", async () => {

View File

@ -24,12 +24,13 @@ import {
SingleUserDependency,
UserDependency,
} from "@bitwarden/common/tools/dependencies";
import { isDynamic } from "@bitwarden/common/tools/state/state-constraints-dependency";
import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject";
import { Constraints } from "@bitwarden/common/tools/types";
import { PolicyEvaluator, Randomizer } from "../abstractions";
import { mapPolicyToEvaluatorV2 } from "../rx";
import { Randomizer } from "../abstractions";
import { mapPolicyToConstraints } from "../rx";
import { CredentialGeneratorConfiguration as Configuration } from "../types/credential-generator-configuration";
import { GeneratorConstraints } from "../types/generator-constraints";
type Policy$Dependencies = UserDependency;
type Settings$Dependencies = Partial<UserDependency>;
@ -44,9 +45,6 @@ type Generate$Dependencies = Simplify<Partial<OnDependency> & Partial<UserDepend
*/
website$?: Observable<string>;
};
// FIXME: once the modernization is complete, switch the type parameters
// in `PolicyEvaluator<P, S>` and bake-in the constraints type.
type Evaluator<Settings, Policy> = PolicyEvaluator<Policy, Settings> & Constraints<Settings>;
export class CredentialGeneratorService {
constructor(
@ -61,7 +59,7 @@ export class CredentialGeneratorService {
* this emits. Otherwise, a new credential is emitted when the settings
* update.
*/
generate$<Settings, Policy>(
generate$<Settings extends object, Policy>(
configuration: Readonly<Configuration<Settings, Policy>>,
dependencies?: Generate$Dependencies,
) {
@ -96,7 +94,7 @@ export class CredentialGeneratorService {
* @returns an observable that emits settings
* @remarks the observable enforces policies on the settings
*/
settings$<Settings, Policy>(
settings$<Settings extends object, Policy>(
configuration: Configuration<Settings, Policy>,
dependencies?: Settings$Dependencies,
) {
@ -118,10 +116,9 @@ export class CredentialGeneratorService {
const settings$ = combineLatest([state$, this.policy$(configuration, { userId$ })]).pipe(
map(([settings, policy]) => {
// FIXME: create `onLoadApply` that wraps these operations
const applied = policy.applyPolicy(settings);
const sanitized = policy.sanitize(applied);
return sanitized;
const calibration = isDynamic(policy) ? policy.calibrate(settings) : policy;
const adjusted = calibration.adjust(settings);
return adjusted;
}),
);
@ -135,7 +132,7 @@ export class CredentialGeneratorService {
* `dependencies.singleUserId$` becomes available.
* @remarks the subject enforces policy for the settings
*/
async settings<Settings, Policy>(
async settings<Settings extends object, Policy>(
configuration: Readonly<Configuration<Settings, Policy>>,
dependencies: SingleUserDependency,
) {
@ -143,19 +140,14 @@ export class CredentialGeneratorService {
dependencies.singleUserId$.pipe(filter((userId) => !!userId)),
);
const state = this.stateProvider.getUser(userId, configuration.settings.account);
const constraints$ = this.policy$(configuration, { userId$: dependencies.singleUserId$ });
// FIXME: apply policy to the settings - this should happen *within* the subject.
// Note that policies could be evaluated when the settings are saved or when they
// are loaded. The existing subject presently could only apply settings on save
// (by wiring the policy in as a dependency and applying with "nextState"), and
// even that has a limitation since arbitrary dependencies do not trigger state
// emissions.
const subject = new UserStateSubject(state, dependencies);
const subject = new UserStateSubject(state, { ...dependencies, constraints$ });
return subject;
}
/** Get the policy for the provided configuration
/** Get the policy constraints for the provided configuration
* @param dependencies.userId$ determines which user's policy is loaded
* @returns an observable that emits the policy once `dependencies.userId$`
* and the policy become available.
@ -163,20 +155,20 @@ export class CredentialGeneratorService {
policy$<Settings, Policy>(
configuration: Configuration<Settings, Policy>,
dependencies: Policy$Dependencies,
): Observable<Evaluator<Settings, Policy>> {
): Observable<GeneratorConstraints<Settings>> {
const completion$ = dependencies.userId$.pipe(ignoreElements(), endWith(true));
const policy$ = dependencies.userId$.pipe(
const constraints$ = dependencies.userId$.pipe(
mergeMap((userId) => {
// complete policy emissions otherwise `mergeMap` holds `policy$` open indefinitely
// complete policy emissions otherwise `mergeMap` holds `policies$` open indefinitely
const policies$ = this.policyService
.getAll$(configuration.policy.type, userId)
.pipe(takeUntil(completion$));
return policies$;
}),
mapPolicyToEvaluatorV2(configuration.policy),
mapPolicyToConstraints(configuration.policy),
);
return policy$;
return constraints$;
}
}

View File

@ -0,0 +1,11 @@
import {
DynamicStateConstraints,
PolicyConstraints,
StateConstraints,
} from "@bitwarden/common/tools/types";
/** Specializes state constraints to include policy. */
export type GeneratorConstraints<Settings> = { constraints: PolicyConstraints<Settings> } & (
| DynamicStateConstraints<Settings>
| StateConstraints<Settings>
);

View File

@ -5,6 +5,7 @@ export * from "./credential-generator";
export * from "./credential-generator-configuration";
export * from "./eff-username-generator-options";
export * from "./forwarder-options";
export * from "./generator-constraints";
export * from "./generated-credential";
export * from "./generator-options";
export * from "./generator-type";

View File

@ -1,3 +1,61 @@
/** Settings format for password credential generation.
*/
export type PasswordGeneratorSettings = {
/** The length of the password selected by the user */
length: number;
/** `true` when ambiguous characters may be included in the output.
* `false` when ambiguous characters should not be included in the output.
*/
ambiguous: boolean;
/** `true` when uppercase ASCII characters should be included in the output
* This value defaults to `false.
*/
uppercase: boolean;
/** The minimum number of uppercase characters to include in the output.
* The value is ignored when `uppercase` is `false`.
* The value defaults to 1 when `uppercase` is `true`.
*/
minUppercase: number;
/** `true` when lowercase ASCII characters should be included in the output.
* This value defaults to `false`.
*/
lowercase: boolean;
/** The minimum number of lowercase characters to include in the output.
* The value defaults to 1 when `lowercase` is `true`.
* The value defaults to 0 when `lowercase` is `false`.
*/
minLowercase: number;
/** Whether or not to include ASCII digits in the output
* This value defaults to `true` when `minNumber` is at least 1.
* This value defaults to `false` when `minNumber` is less than 1.
*/
number: boolean;
/** The minimum number of digits to include in the output.
* The value defaults to 1 when `number` is `true`.
* The value defaults to 0 when `number` is `false`.
*/
minNumber: number;
/** Whether or not to include special characters in the output.
* This value defaults to `true` when `minSpecial` is at least 1.
* This value defaults to `false` when `minSpecial` is less than 1.
*/
special: boolean;
/** The minimum number of special characters to include in the output.
* This value defaults to 1 when `special` is `true`.
* This value defaults to 0 when `special` is `false`.
*/
minSpecial: number;
};
/** Request format for password credential generation.
* All members of this type may be `undefined` when the user is
* generating a passphrase.
@ -6,63 +64,9 @@
* it is used with the "password generator" types. The name
* `PasswordGeneratorOptions` is already in use by legacy code.
*/
export type PasswordGenerationOptions = {
/** The length of the password selected by the user */
length?: number;
export type PasswordGenerationOptions = Partial<PasswordGeneratorSettings> & {
/** The minimum length of the password. This defaults to 5, and increases
* to ensure `minLength` is at least as large as the sum of the other minimums.
*/
minLength?: number;
/** `true` when ambiguous characters may be included in the output.
* `false` when ambiguous characters should not be included in the output.
*/
ambiguous?: boolean;
/** `true` when uppercase ASCII characters should be included in the output
* This value defaults to `false.
*/
uppercase?: boolean;
/** The minimum number of uppercase characters to include in the output.
* The value is ignored when `uppercase` is `false`.
* The value defaults to 1 when `uppercase` is `true`.
*/
minUppercase?: number;
/** `true` when lowercase ASCII characters should be included in the output.
* This value defaults to `false`.
*/
lowercase?: boolean;
/** The minimum number of lowercase characters to include in the output.
* The value defaults to 1 when `lowercase` is `true`.
* The value defaults to 0 when `lowercase` is `false`.
*/
minLowercase?: number;
/** Whether or not to include ASCII digits in the output
* This value defaults to `true` when `minNumber` is at least 1.
* This value defaults to `false` when `minNumber` is less than 1.
*/
number?: boolean;
/** The minimum number of digits to include in the output.
* The value defaults to 1 when `number` is `true`.
* The value defaults to 0 when `number` is `false`.
*/
minNumber?: number;
/** Whether or not to include special characters in the output.
* This value defaults to `true` when `minSpecial` is at least 1.
* This value defaults to `false` when `minSpecial` is less than 1.
*/
special?: boolean;
/** The minimum number of special characters to include in the output.
* This value defaults to 1 when `special` is `true`.
* This value defaults to 0 when `special` is `false`.
*/
minSpecial?: number;
};

View File

@ -1,9 +1,10 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy as AdminPolicy } from "@bitwarden/common/admin-console/models/domain/policy";
import { Constraints } from "@bitwarden/common/tools/types";
import { PolicyEvaluator } from "../abstractions";
import { GeneratorConstraints } from "./generator-constraints";
/** Determines how to construct a password generator policy */
export type PolicyConfiguration<Policy, Settings> = {
type: PolicyType;
@ -17,13 +18,15 @@ export type PolicyConfiguration<Policy, Settings> = {
combine: (acc: Policy, policy: AdminPolicy) => Policy;
/** Converts policy service data into an actionable policy.
* @deprecated provided only for backwards compatibility.
* Use `toConstraints` instead.
*/
createEvaluator: (policy: Policy) => PolicyEvaluator<Policy, Settings>;
/** Converts policy service data into an actionable policy.
/** Converts policy service data into actionable policy constraints.
* @remarks this version includes constraints needed for the reactive forms;
* it was introduced so that the constraints can be incrementally introduced
* as the new UI is built.
*/
createEvaluatorV2?: (policy: Policy) => PolicyEvaluator<Policy, Settings> & Constraints<Settings>;
toConstraints: (policy: Policy) => GeneratorConstraints<Settings>;
};