diff --git a/libs/tools/generator/core/src/engine/index.ts b/libs/tools/generator/core/src/engine/index.ts index 2d272e7c11b..f8008a866e4 100644 --- a/libs/tools/generator/core/src/engine/index.ts +++ b/libs/tools/generator/core/src/engine/index.ts @@ -5,4 +5,5 @@ export * from "./settings"; export { EmailRandomizer } from "./email-randomizer"; export { EmailCalculator } from "./email-calculator"; export { PasswordRandomizer } from "./password-randomizer"; +export { SdkPasswordRandomizer } from "./sdk-password-randomizer"; export { UsernameRandomizer } from "./username-randomizer"; diff --git a/libs/tools/generator/core/src/engine/sdk-password-randomizer.ts b/libs/tools/generator/core/src/engine/sdk-password-randomizer.ts new file mode 100644 index 00000000000..03be21eeefb --- /dev/null +++ b/libs/tools/generator/core/src/engine/sdk-password-randomizer.ts @@ -0,0 +1,103 @@ +import { + BitwardenClient, + PassphraseGeneratorRequest, + PasswordGeneratorRequest, +} from "@bitwarden/sdk-internal"; + +import { Type } from "../metadata"; +import { + CredentialGenerator, + GenerateRequest, + GeneratedCredential, + PassphraseGenerationOptions, + PasswordGenerationOptions, +} from "../types"; + +/** Generation algorithms that produce randomized secrets by calling on functionality from the SDK */ +export class SdkPasswordRandomizer + implements + CredentialGenerator, + CredentialGenerator +{ + /** Instantiates the password randomizer + * @param client access to SDK client to call upon password/passphrase generation + * @param currentTime gets the current datetime in epoch time + */ + constructor( + private client: BitwardenClient, + private currentTime: () => number, + ) {} + + generate( + request: GenerateRequest, + settings: PasswordGenerationOptions, + ): Promise; + generate( + request: GenerateRequest, + settings: PassphraseGenerationOptions, + ): Promise; + async generate( + request: GenerateRequest, + settings: PasswordGenerationOptions | PassphraseGenerationOptions, + ) { + if (isPasswordGenerationOptions(settings)) { + const password = await this.client.generator().password(convertPasswordRequest(settings)); + + return new GeneratedCredential( + password, + Type.password, + this.currentTime(), + request.source, + request.website, + ); + } else if (isPassphraseGenerationOptions(settings)) { + const passphrase = await this.client + .generator() + .passphrase(convertPassphraseRequest(settings)); + + return new GeneratedCredential( + passphrase, + Type.password, + this.currentTime(), + request.source, + request.website, + ); + } + + throw new Error("Invalid settings received by generator."); + } +} + +function convertPasswordRequest(settings: PasswordGenerationOptions): PasswordGeneratorRequest { + return { + lowercase: settings.lowercase!, + uppercase: settings.uppercase!, + numbers: settings.number!, + special: settings.special!, + length: settings.length!, + avoidAmbiguous: settings.ambiguous!, + minLowercase: settings.minLowercase!, + minUppercase: settings.minUppercase!, + minNumber: settings.minNumber!, + minSpecial: settings.minSpecial!, + }; +} + +function convertPassphraseRequest( + settings: PassphraseGenerationOptions, +): PassphraseGeneratorRequest { + return { + numWords: settings.numWords!, + wordSeparator: settings.wordSeparator!, + capitalize: settings.capitalize!, + includeNumber: settings.includeNumber!, + }; +} + +function isPasswordGenerationOptions(settings: any): settings is PasswordGenerationOptions { + return "length" in (settings ?? {}); +} + +function isPassphraseGenerationOptions(settings: any): settings is PassphraseGenerationOptions { + return "numWords" in (settings ?? {}); +} diff --git a/libs/tools/generator/core/src/metadata/data.ts b/libs/tools/generator/core/src/metadata/data.ts index 2b9dad50557..5ac6cac7222 100644 --- a/libs/tools/generator/core/src/metadata/data.ts +++ b/libs/tools/generator/core/src/metadata/data.ts @@ -8,6 +8,12 @@ export const Algorithm = Object.freeze({ /** A password composed of random words from the EFF word list */ passphrase: "passphrase", + /** A password composed of random characters, retrieved from SDK */ + sdkPassword: "sdkPassword", + + /** A password composed of random words from the EFF word list, retrieved from SDK */ + sdkPassphrase: "sdkPassphrase", + /** A username composed of words from the EFF word list */ username: "username", @@ -38,7 +44,12 @@ export const Profile = Object.freeze({ /** Credential generation algorithms grouped by purpose. */ export const AlgorithmsByType = deepFreeze({ /** Algorithms that produce passwords */ - [Type.password]: [Algorithm.password, Algorithm.passphrase] as const, + [Type.password]: [ + Algorithm.password, + Algorithm.passphrase, + Algorithm.sdkPassword, + Algorithm.sdkPassphrase, + ] as const, /** Algorithms that produce usernames */ [Type.username]: [Algorithm.username] as const, diff --git a/libs/tools/generator/core/src/metadata/password/sdk-eff-word-list.spec.ts b/libs/tools/generator/core/src/metadata/password/sdk-eff-word-list.spec.ts new file mode 100644 index 00000000000..03f1275f73c --- /dev/null +++ b/libs/tools/generator/core/src/metadata/password/sdk-eff-word-list.spec.ts @@ -0,0 +1,106 @@ +import { mock } from "jest-mock-extended"; + +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; + +import { SdkPasswordRandomizer } from "../../engine"; +import { PassphrasePolicyConstraints } from "../../policies"; +import { PassphraseGenerationOptions, GeneratorDependencyProvider } from "../../types"; +import { Profile } from "../data"; +import { CoreProfileMetadata } from "../profile-metadata"; +import { isCoreProfile } from "../util"; + +import sdkEffPassphrase from "./sdk-eff-word-list"; + +const dependencyProvider = mock(); + +describe("password - eff words generator metadata", () => { + describe("engine.create", () => { + it("returns an email randomizer", () => { + expect(sdkEffPassphrase.engine.create(dependencyProvider)).toBeInstanceOf( + SdkPasswordRandomizer, + ); + }); + }); + + describe("profiles[account]", () => { + let accountProfile: CoreProfileMetadata | null = null; + beforeEach(() => { + const profile = sdkEffPassphrase.profiles[Profile.account]; + if (isCoreProfile(profile!)) { + accountProfile = profile; + } else { + accountProfile = null; + } + }); + + describe("storage.options.deserializer", () => { + it("returns its input", () => { + const value: PassphraseGenerationOptions = { ...accountProfile!.storage.initial }; + + const result = accountProfile!.storage.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("constraints.create", () => { + // these tests check that the wiring is correct by exercising the behavior + // of functionality encapsulated by `create`. These methods may fail if the + // enclosed behaviors change. + + it("creates a passphrase policy constraints", () => { + const context = { defaultConstraints: accountProfile!.constraints.default }; + + const constraints = accountProfile!.constraints.create([], context); + + expect(constraints).toBeInstanceOf(PassphrasePolicyConstraints); + }); + + it("forwards the policy to the constraints", () => { + const context = { defaultConstraints: accountProfile!.constraints.default }; + const policies = [ + { + type: PolicyType.PasswordGenerator, + data: { + minNumberWords: 6, + capitalize: false, + includeNumber: false, + }, + }, + ] as Policy[]; + + const constraints = accountProfile!.constraints.create(policies, context); + + expect(constraints.constraints.numWords?.min).toEqual(6); + }); + + it("combines multiple policies in the constraints", () => { + const context = { defaultConstraints: accountProfile!.constraints.default }; + const policies = [ + { + type: PolicyType.PasswordGenerator, + data: { + minNumberWords: 6, + capitalize: false, + includeNumber: false, + }, + }, + { + type: PolicyType.PasswordGenerator, + data: { + minNumberWords: 3, + capitalize: true, + includeNumber: false, + }, + }, + ] as Policy[]; + + const constraints = accountProfile!.constraints.create(policies, context); + + expect(constraints.constraints.numWords?.min).toEqual(6); + expect(constraints.constraints.capitalize?.requiredValue).toEqual(true); + }); + }); + }); +}); diff --git a/libs/tools/generator/core/src/metadata/password/sdk-eff-word-list.ts b/libs/tools/generator/core/src/metadata/password/sdk-eff-word-list.ts new file mode 100644 index 00000000000..802ef0ef068 --- /dev/null +++ b/libs/tools/generator/core/src/metadata/password/sdk-eff-word-list.ts @@ -0,0 +1,92 @@ +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { GENERATOR_DISK } from "@bitwarden/common/platform/state"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; +import { BitwardenClient } from "@bitwarden/sdk-internal"; + +import { SdkPasswordRandomizer } from "../../engine"; +import { passphraseLeastPrivilege, PassphrasePolicyConstraints } from "../../policies"; +import { + CredentialGenerator, + GeneratorDependencyProvider, + PassphraseGenerationOptions, +} from "../../types"; +import { Algorithm, Profile, Type } from "../data"; +import { GeneratorMetadata } from "../generator-metadata"; + +const sdkPassphrase: GeneratorMetadata = { + id: Algorithm.sdkPassphrase, + category: Type.password, + weight: 130, + i18nKeys: { + name: "passphrase", + credentialType: "passphrase", + generateCredential: "generatePassphrase", + credentialGenerated: "passphraseGenerated", + copyCredential: "copyPassphrase", + useCredential: "useThisPassphrase", + }, + capabilities: { + autogenerate: false, + fields: [], + }, + engine: { + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new SdkPasswordRandomizer(new BitwardenClient(), Date.now); // @TODO hook up a real SDK client + }, + }, + profiles: { + [Profile.account]: { + type: "core", + storage: { + key: "passphraseGeneratorSettings", + target: "object", + format: "plain", + classifier: new PublicClassifier([ + "numWords", + "wordSeparator", + "capitalize", + "includeNumber", + ]), + state: GENERATOR_DISK, + initial: { + numWords: 6, + wordSeparator: "-", + capitalize: false, + includeNumber: false, + }, + options: { + deserializer(value) { + return value; + }, + clearOn: ["logout"], + }, + } satisfies ObjectKey, + constraints: { + type: PolicyType.PasswordGenerator, + default: { + wordSeparator: { maxLength: 1 }, + numWords: { + min: 3, + max: 20, + recommendation: 6, + }, + }, + create(policies, context) { + const initial = { + minNumberWords: 0, + capitalize: false, + includeNumber: false, + }; + const policy = policies.reduce(passphraseLeastPrivilege, initial); + const constraints = new PassphrasePolicyConstraints(policy, context.defaultConstraints); + return constraints; + }, + }, + }, + }, +}; + +export default sdkPassphrase; diff --git a/libs/tools/generator/core/src/metadata/password/sdk-random-password.spec.ts b/libs/tools/generator/core/src/metadata/password/sdk-random-password.spec.ts new file mode 100644 index 00000000000..cec4704789f --- /dev/null +++ b/libs/tools/generator/core/src/metadata/password/sdk-random-password.spec.ts @@ -0,0 +1,107 @@ +import { mock } from "jest-mock-extended"; + +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; + +import { SdkPasswordRandomizer } from "../../engine"; +import { DynamicPasswordPolicyConstraints } from "../../policies"; +import { PasswordGenerationOptions, GeneratorDependencyProvider } from "../../types"; +import { Profile } from "../data"; +import { CoreProfileMetadata } from "../profile-metadata"; +import { isCoreProfile } from "../util"; + +import sdkPassword from "./sdk-random-password"; + +const dependencyProvider = mock(); + +describe("password - characters generator metadata", () => { + describe("engine.create", () => { + it("returns an email randomizer", () => { + expect(sdkPassword.engine.create(dependencyProvider)).toBeInstanceOf(SdkPasswordRandomizer); + }); + }); + + describe("profiles[account]", () => { + let accountProfile: CoreProfileMetadata = null!; + beforeEach(() => { + const profile = sdkPassword.profiles[Profile.account]; + if (isCoreProfile(profile!)) { + accountProfile = profile; + } else { + throw new Error("this branch should never run"); + } + }); + + describe("storage.options.deserializer", () => { + it("returns its input", () => { + const value: PasswordGenerationOptions = { ...accountProfile.storage.initial }; + + const result = accountProfile.storage.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("constraints.create", () => { + // these tests check that the wiring is correct by exercising the behavior + // of functionality encapsulated by `create`. These methods may fail if the + // enclosed behaviors change. + + it("creates a passphrase policy constraints", () => { + const context = { defaultConstraints: accountProfile.constraints.default }; + + const constraints = accountProfile.constraints.create([], context); + + expect(constraints).toBeInstanceOf(DynamicPasswordPolicyConstraints); + }); + + it("forwards the policy to the constraints", () => { + const context = { defaultConstraints: accountProfile.constraints.default }; + const policies = [ + { + type: PolicyType.PasswordGenerator, + enabled: true, + data: { + minLength: 10, + capitalize: false, + useNumbers: false, + }, + }, + ] as Policy[]; + + const constraints = accountProfile.constraints.create(policies, context); + + expect(constraints.constraints.length?.min).toEqual(10); + }); + + it("combines multiple policies in the constraints", () => { + const context = { defaultConstraints: accountProfile.constraints.default }; + const policies = [ + { + type: PolicyType.PasswordGenerator, + enabled: true, + data: { + minLength: 14, + useSpecial: false, + useNumbers: false, + }, + }, + { + type: PolicyType.PasswordGenerator, + enabled: true, + data: { + minLength: 10, + useSpecial: true, + includeNumber: false, + }, + }, + ] as Policy[]; + + const constraints = accountProfile.constraints.create(policies, context); + + expect(constraints.constraints.length?.min).toEqual(14); + expect(constraints.constraints.special?.requiredValue).toEqual(true); + }); + }); + }); +}); diff --git a/libs/tools/generator/core/src/metadata/password/sdk-random-password.ts b/libs/tools/generator/core/src/metadata/password/sdk-random-password.ts new file mode 100644 index 00000000000..d4bb6263b1e --- /dev/null +++ b/libs/tools/generator/core/src/metadata/password/sdk-random-password.ts @@ -0,0 +1,118 @@ +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { GENERATOR_DISK } from "@bitwarden/common/platform/state"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; +import { deepFreeze } from "@bitwarden/common/tools/util"; +import { BitwardenClient } from "@bitwarden/sdk-internal"; + +import { SdkPasswordRandomizer } from "../../engine"; +import { DynamicPasswordPolicyConstraints, passwordLeastPrivilege } from "../../policies"; +import { + CredentialGenerator, + GeneratorDependencyProvider, + PasswordGeneratorSettings, +} from "../../types"; +import { Algorithm, Profile, Type } from "../data"; +import { GeneratorMetadata } from "../generator-metadata"; + +const sdkPassword: GeneratorMetadata = deepFreeze({ + id: Algorithm.sdkPassword, + category: Type.password, + weight: 120, + i18nKeys: { + name: "password", + generateCredential: "generatePassword", + credentialGenerated: "passwordGenerated", + credentialType: "password", + copyCredential: "copyPassword", + useCredential: "useThisPassword", + }, + capabilities: { + autogenerate: true, + fields: [], + }, + engine: { + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new SdkPasswordRandomizer(new BitwardenClient(), Date.now); // @TODO hook up a real SDK client + }, + }, + profiles: { + [Profile.account]: { + type: "core", + storage: { + key: "passwordGeneratorSettings", + target: "object", + format: "plain", + classifier: new PublicClassifier([ + "length", + "ambiguous", + "uppercase", + "minUppercase", + "lowercase", + "minLowercase", + "number", + "minNumber", + "special", + "minSpecial", + ]), + state: GENERATOR_DISK, + initial: { + length: 14, + ambiguous: true, + uppercase: true, + minUppercase: 1, + lowercase: true, + minLowercase: 1, + number: true, + minNumber: 1, + special: false, + minSpecial: 0, + }, + options: { + deserializer(value) { + return value; + }, + clearOn: ["logout"], + }, + }, + constraints: { + type: PolicyType.PasswordGenerator, + default: { + length: { + min: 5, + max: 128, + recommendation: 14, + }, + minNumber: { + min: 0, + max: 9, + }, + minSpecial: { + min: 0, + max: 9, + }, + }, + create(policies, context) { + const initial = { + minLength: 0, + useUppercase: false, + useLowercase: false, + useNumbers: false, + numberCount: 0, + useSpecial: false, + specialCount: 0, + }; + const policy = policies.reduce(passwordLeastPrivilege, initial); + const constraints = new DynamicPasswordPolicyConstraints( + policy, + context.defaultConstraints, + ); + return constraints; + }, + }, + }, + }, +}); + +export default sdkPassword;