-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[PM-15061] extract encryptors from generator service (#12068)
* introduce legacy encryptor provider * port credential generation service to encryptor provider
- Loading branch information
1 parent
927c2fc
commit ab21b78
Showing
33 changed files
with
1,378 additions
and
293 deletions.
There are no files selected for viewing
492 changes: 492 additions & 0 deletions
492
libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.spec.ts
Large diffs are not rendered by default.
Oops, something went wrong.
132 changes: 132 additions & 0 deletions
132
libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
import { | ||
connect, | ||
dematerialize, | ||
map, | ||
materialize, | ||
ReplaySubject, | ||
skipWhile, | ||
switchMap, | ||
takeUntil, | ||
takeWhile, | ||
} from "rxjs"; | ||
|
||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; | ||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; | ||
import { KeyService } from "@bitwarden/key-management"; | ||
|
||
import { | ||
OrganizationBound, | ||
SingleOrganizationDependency, | ||
SingleUserDependency, | ||
UserBound, | ||
} from "../dependencies"; | ||
import { anyComplete, errorOnChange } from "../rx"; | ||
import { PaddedDataPacker } from "../state/padded-data-packer"; | ||
|
||
import { LegacyEncryptorProvider } from "./legacy-encryptor-provider"; | ||
import { OrganizationEncryptor } from "./organization-encryptor.abstraction"; | ||
import { OrganizationKeyEncryptor } from "./organization-key-encryptor"; | ||
import { UserEncryptor } from "./user-encryptor.abstraction"; | ||
import { UserKeyEncryptor } from "./user-key-encryptor"; | ||
|
||
/** Creates encryptors | ||
*/ | ||
export class KeyServiceLegacyEncryptorProvider implements LegacyEncryptorProvider { | ||
/** Instantiates the legacy encryptor provider. | ||
* @param encryptService injected into encryptors to perform encryption | ||
* @param keyService looks up keys for construction into an encryptor | ||
*/ | ||
constructor( | ||
private readonly encryptService: EncryptService, | ||
private readonly keyService: KeyService, | ||
) {} | ||
|
||
userEncryptor$(frameSize: number, dependencies: SingleUserDependency) { | ||
const packer = new PaddedDataPacker(frameSize); | ||
const encryptor$ = dependencies.singleUserId$.pipe( | ||
errorOnChange( | ||
(userId) => userId, | ||
(expectedUserId, actualUserId) => ({ expectedUserId, actualUserId }), | ||
), | ||
connect((singleUserId$) => { | ||
const singleUserId = new ReplaySubject<UserId>(1); | ||
singleUserId$.subscribe(singleUserId); | ||
|
||
return singleUserId.pipe( | ||
switchMap((userId) => | ||
this.keyService.userKey$(userId).pipe( | ||
// wait until the key becomes available | ||
skipWhile((key) => !key), | ||
// complete when the key becomes unavailable | ||
takeWhile((key) => !!key), | ||
map((key) => { | ||
const encryptor = new UserKeyEncryptor(userId, this.encryptService, key, packer); | ||
|
||
return { userId, encryptor } satisfies UserBound<"encryptor", UserEncryptor>; | ||
}), | ||
materialize(), | ||
), | ||
), | ||
dematerialize(), | ||
takeUntil(anyComplete(singleUserId)), | ||
); | ||
}), | ||
); | ||
|
||
return encryptor$; | ||
} | ||
|
||
organizationEncryptor$(frameSize: number, dependencies: SingleOrganizationDependency) { | ||
const packer = new PaddedDataPacker(frameSize); | ||
const encryptor$ = dependencies.singleOrganizationId$.pipe( | ||
errorOnChange( | ||
(pair) => pair.userId, | ||
(expectedUserId, actualUserId) => ({ expectedUserId, actualUserId }), | ||
), | ||
errorOnChange( | ||
(pair) => pair.organizationId, | ||
(expectedOrganizationId, actualOrganizationId) => ({ | ||
expectedOrganizationId, | ||
actualOrganizationId, | ||
}), | ||
), | ||
connect((singleOrganizationId$) => { | ||
const singleOrganizationId = new ReplaySubject<UserBound<"organizationId", OrganizationId>>( | ||
1, | ||
); | ||
singleOrganizationId$.subscribe(singleOrganizationId); | ||
|
||
return singleOrganizationId.pipe( | ||
switchMap((pair) => | ||
this.keyService.orgKeys$(pair.userId).pipe( | ||
// wait until the key becomes available | ||
skipWhile((keys) => !keys), | ||
// complete when the key becomes unavailable | ||
takeWhile((keys) => !!keys), | ||
map((keys) => { | ||
const organizationId = pair.organizationId; | ||
const key = keys[organizationId]; | ||
const encryptor = new OrganizationKeyEncryptor( | ||
organizationId, | ||
this.encryptService, | ||
key, | ||
packer, | ||
); | ||
|
||
return { organizationId, encryptor } satisfies OrganizationBound< | ||
"encryptor", | ||
OrganizationEncryptor | ||
>; | ||
}), | ||
materialize(), | ||
), | ||
), | ||
dematerialize(), | ||
takeUntil(anyComplete(singleOrganizationId)), | ||
); | ||
}), | ||
); | ||
|
||
return encryptor$; | ||
} | ||
} |
42 changes: 42 additions & 0 deletions
42
libs/common/src/tools/cryptography/legacy-encryptor-provider.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import { Observable } from "rxjs"; | ||
|
||
import { | ||
OrganizationBound, | ||
SingleOrganizationDependency, | ||
SingleUserDependency, | ||
UserBound, | ||
} from "../dependencies"; | ||
|
||
import { OrganizationEncryptor } from "./organization-encryptor.abstraction"; | ||
import { UserEncryptor } from "./user-encryptor.abstraction"; | ||
|
||
/** Creates encryptors | ||
* @deprecated this logic will soon be replaced with a design that provides for | ||
* key rotation. Use it at your own risk | ||
*/ | ||
export abstract class LegacyEncryptorProvider { | ||
/** Retrieves an encryptor populated with the user's most recent key instance that | ||
* uses a padded data packer to encode data. | ||
* @param frameSize length of the padded data packer's frames. | ||
* @param dependencies.singleUserId$ identifies the user to which the encryptor is bound | ||
* @returns an observable that emits when the key becomes available and completes | ||
* when the key becomes unavailable. | ||
*/ | ||
userEncryptor$: ( | ||
frameSize: number, | ||
dependencies: SingleUserDependency, | ||
) => Observable<UserBound<"encryptor", UserEncryptor>>; | ||
|
||
/** Retrieves an encryptor populated with the organization's most recent key instance that | ||
* uses a padded data packer to encode data. | ||
* @param frameSize length of the padded data packer's frames. | ||
* @param dependencies.singleOrganizationId$ identifies the user/org combination | ||
* to which the encryptor is bound. | ||
* @returns an observable that emits when the key becomes available and completes | ||
* when the key becomes unavailable. | ||
*/ | ||
organizationEncryptor$: ( | ||
frameSize: number, | ||
dependences: SingleOrganizationDependency, | ||
) => Observable<OrganizationBound<"encryptor", OrganizationEncryptor>>; | ||
} |
33 changes: 33 additions & 0 deletions
33
libs/common/src/tools/cryptography/organization-encryptor.abstraction.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
import { Jsonify } from "type-fest"; | ||
|
||
import { OrganizationId } from "@bitwarden/common/types/guid"; | ||
|
||
import { EncString } from "../../platform/models/domain/enc-string"; | ||
|
||
/** An encryption strategy that protects a type's secrets with | ||
* organization-specific keys. This strategy is bound to a specific organization. | ||
*/ | ||
export abstract class OrganizationEncryptor { | ||
/** Identifies the organization bound to the encryptor. */ | ||
readonly organizationId: OrganizationId; | ||
|
||
/** Protects secrets in `value` with an organization-specific key. | ||
* @param secret the object to protect. This object is mutated during encryption. | ||
* @returns a promise that resolves to a tuple. The tuple's first property contains | ||
* the encrypted secret and whose second property contains an object w/ disclosed | ||
* properties. | ||
* @throws If `value` is `null` or `undefined`, the promise rejects with an error. | ||
*/ | ||
abstract encrypt<Secret>(secret: Jsonify<Secret>): Promise<EncString>; | ||
|
||
/** Combines protected secrets and disclosed data into a type that can be | ||
* rehydrated into a domain object. | ||
* @param secret an encrypted JSON payload containing encrypted secrets. | ||
* @returns a promise that resolves to the raw state. This state *is not* a | ||
* class. It contains only data that can be round-tripped through JSON, | ||
* and lacks members such as a prototype or bound functions. | ||
* @throws If `secret` or `disclosed` is `null` or `undefined`, the promise | ||
* rejects with an error. | ||
*/ | ||
abstract decrypt<Secret>(secret: EncString): Promise<Jsonify<Secret>>; | ||
} |
125 changes: 125 additions & 0 deletions
125
libs/common/src/tools/cryptography/organization-key-encryptor.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,125 @@ | ||
import { mock } from "jest-mock-extended"; | ||
|
||
import { EncryptService } from "../../platform/abstractions/encrypt.service"; | ||
import { EncString } from "../../platform/models/domain/enc-string"; | ||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; | ||
import { CsprngArray } from "../../types/csprng"; | ||
import { OrganizationId } from "../../types/guid"; | ||
import { OrgKey } from "../../types/key"; | ||
import { DataPacker } from "../state/data-packer.abstraction"; | ||
|
||
import { OrganizationKeyEncryptor } from "./organization-key-encryptor"; | ||
|
||
describe("OrgKeyEncryptor", () => { | ||
const encryptService = mock<EncryptService>(); | ||
const dataPacker = mock<DataPacker>(); | ||
const orgKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as OrgKey; | ||
const anyOrgId = "foo" as OrganizationId; | ||
|
||
beforeEach(() => { | ||
// The OrgKeyEncryptor is, in large part, a facade coordinating a handful of worker | ||
// objects, so its tests focus on how data flows between components. The defaults rely | ||
// on this property--that the facade treats its data like a opaque objects--to trace | ||
// the data through several function calls. Should the encryptor interact with the | ||
// objects themselves, these mocks will break. | ||
encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString)); | ||
encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c as unknown as string)); | ||
dataPacker.pack.mockImplementation((v) => v as string); | ||
dataPacker.unpack.mockImplementation(<T>(v: string) => v as T); | ||
}); | ||
|
||
afterEach(() => { | ||
jest.resetAllMocks(); | ||
}); | ||
|
||
describe("constructor", () => { | ||
it("should set organizationId", async () => { | ||
const encryptor = new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, dataPacker); | ||
expect(encryptor.organizationId).toEqual(anyOrgId); | ||
}); | ||
|
||
it("should throw if organizationId was not supplied", async () => { | ||
expect(() => new OrganizationKeyEncryptor(null, encryptService, orgKey, dataPacker)).toThrow( | ||
"organizationId cannot be null or undefined", | ||
); | ||
expect(() => new OrganizationKeyEncryptor(null, encryptService, orgKey, dataPacker)).toThrow( | ||
"organizationId cannot be null or undefined", | ||
); | ||
}); | ||
|
||
it("should throw if encryptService was not supplied", async () => { | ||
expect(() => new OrganizationKeyEncryptor(anyOrgId, null, orgKey, dataPacker)).toThrow( | ||
"encryptService cannot be null or undefined", | ||
); | ||
expect(() => new OrganizationKeyEncryptor(anyOrgId, null, orgKey, dataPacker)).toThrow( | ||
"encryptService cannot be null or undefined", | ||
); | ||
}); | ||
|
||
it("should throw if key was not supplied", async () => { | ||
expect( | ||
() => new OrganizationKeyEncryptor(anyOrgId, encryptService, null, dataPacker), | ||
).toThrow("key cannot be null or undefined"); | ||
expect( | ||
() => new OrganizationKeyEncryptor(anyOrgId, encryptService, null, dataPacker), | ||
).toThrow("key cannot be null or undefined"); | ||
}); | ||
|
||
it("should throw if dataPacker was not supplied", async () => { | ||
expect(() => new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, null)).toThrow( | ||
"dataPacker cannot be null or undefined", | ||
); | ||
expect(() => new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, null)).toThrow( | ||
"dataPacker cannot be null or undefined", | ||
); | ||
}); | ||
}); | ||
|
||
describe("encrypt", () => { | ||
it("should throw if value was not supplied", async () => { | ||
const encryptor = new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, dataPacker); | ||
|
||
await expect(encryptor.encrypt<Record<string, never>>(null)).rejects.toThrow( | ||
"secret cannot be null or undefined", | ||
); | ||
await expect(encryptor.encrypt<Record<string, never>>(undefined)).rejects.toThrow( | ||
"secret cannot be null or undefined", | ||
); | ||
}); | ||
|
||
it("should encrypt a packed value using the organization's key", async () => { | ||
const encryptor = new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, dataPacker); | ||
const value = { foo: true }; | ||
|
||
const result = await encryptor.encrypt(value); | ||
|
||
// these are data flow expectations; the operations all all pass-through mocks | ||
expect(dataPacker.pack).toHaveBeenCalledWith(value); | ||
expect(encryptService.encrypt).toHaveBeenCalledWith(value, orgKey); | ||
expect(result).toBe(value); | ||
}); | ||
}); | ||
|
||
describe("decrypt", () => { | ||
it("should throw if secret was not supplied", async () => { | ||
const encryptor = new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, dataPacker); | ||
|
||
await expect(encryptor.decrypt(null)).rejects.toThrow("secret cannot be null or undefined"); | ||
await expect(encryptor.decrypt(undefined)).rejects.toThrow( | ||
"secret cannot be null or undefined", | ||
); | ||
}); | ||
|
||
it("should declassify a decrypted packed value using the organization's key", async () => { | ||
const encryptor = new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, dataPacker); | ||
const secret = "encrypted" as any; | ||
|
||
const result = await encryptor.decrypt(secret); | ||
|
||
// these are data flow expectations; the operations all all pass-through mocks | ||
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(secret, orgKey); | ||
expect(dataPacker.unpack).toHaveBeenCalledWith(secret); | ||
expect(result).toBe(secret); | ||
}); | ||
}); | ||
}); |
60 changes: 60 additions & 0 deletions
60
libs/common/src/tools/cryptography/organization-key-encryptor.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
import { Jsonify } from "type-fest"; | ||
|
||
import { OrganizationId } from "@bitwarden/common/types/guid"; | ||
|
||
import { EncryptService } from "../../platform/abstractions/encrypt.service"; | ||
import { EncString } from "../../platform/models/domain/enc-string"; | ||
import { OrgKey } from "../../types/key"; | ||
import { DataPacker } from "../state/data-packer.abstraction"; | ||
|
||
import { OrganizationEncryptor } from "./organization-encryptor.abstraction"; | ||
|
||
/** A classification strategy that protects a type's secrets by encrypting them | ||
* with an `OrgKey` | ||
*/ | ||
export class OrganizationKeyEncryptor extends OrganizationEncryptor { | ||
/** Instantiates the encryptor | ||
* @param organizationId identifies the organization bound to the encryptor. | ||
* @param encryptService protects properties of `Secret`. | ||
* @param key the key instance protecting the data. | ||
* @param dataPacker packs and unpacks data classified as secrets. | ||
*/ | ||
constructor( | ||
readonly organizationId: OrganizationId, | ||
private readonly encryptService: EncryptService, | ||
private readonly key: OrgKey, | ||
private readonly dataPacker: DataPacker, | ||
) { | ||
super(); | ||
this.assertHasValue("organizationId", organizationId); | ||
this.assertHasValue("key", key); | ||
this.assertHasValue("dataPacker", dataPacker); | ||
this.assertHasValue("encryptService", encryptService); | ||
} | ||
|
||
async encrypt<Secret>(secret: Jsonify<Secret>): Promise<EncString> { | ||
this.assertHasValue("secret", secret); | ||
|
||
let packed = this.dataPacker.pack(secret); | ||
const encrypted = await this.encryptService.encrypt(packed, this.key); | ||
packed = null; | ||
|
||
return encrypted; | ||
} | ||
|
||
async decrypt<Secret>(secret: EncString): Promise<Jsonify<Secret>> { | ||
this.assertHasValue("secret", secret); | ||
|
||
let decrypted = await this.encryptService.decryptToUtf8(secret, this.key); | ||
const unpacked = this.dataPacker.unpack<Secret>(decrypted); | ||
decrypted = null; | ||
|
||
return unpacked; | ||
} | ||
|
||
private assertHasValue(name: string, value: any) { | ||
if (value === undefined || value === null) { | ||
throw new Error(`${name} cannot be null or undefined`); | ||
} | ||
} | ||
} |
File renamed without changes.
Oops, something went wrong.