1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 07:43:35 +00:00

[PM-15061] extract encryptors from generator service (#12068)

* introduce legacy encryptor provider
* port credential generation service to encryptor provider
This commit is contained in:
✨ Audrey ✨
2024-11-28 05:02:21 -05:00
committed by GitHub
parent 927c2fce43
commit ab21b78c53
33 changed files with 1384 additions and 299 deletions

View File

@@ -0,0 +1,492 @@
import { mock } from "jest-mock-extended";
import { BehaviorSubject, Subject } from "rxjs";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { OrgKey, UserKey } from "@bitwarden/common/types/key";
import { KeyService } from "@bitwarden/key-management";
import { OrganizationBound, UserBound } from "../dependencies";
import { KeyServiceLegacyEncryptorProvider } from "./key-service-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";
const encryptService = mock<EncryptService>();
const keyService = mock<KeyService>();
const SomeCsprngArray = new Uint8Array(64) as CsprngArray;
const SomeUser = "some user" as UserId;
const AnotherUser = "another user" as UserId;
const SomeUserKey = new SymmetricCryptoKey(SomeCsprngArray) as UserKey;
const SomeOrganization = "some organization" as OrganizationId;
const AnotherOrganization = "another organization" as OrganizationId;
const SomeOrgKey = new SymmetricCryptoKey(SomeCsprngArray) as OrgKey;
const AnotherOrgKey = new SymmetricCryptoKey(SomeCsprngArray) as OrgKey;
const OrgRecords: Record<OrganizationId, OrgKey> = {
[SomeOrganization]: SomeOrgKey,
[AnotherOrganization]: AnotherOrgKey,
};
// Many tests examine the private members of the objects constructed by the
// provider. This is necessary because it's not presently possible to spy
// on the constructors directly.
describe("KeyServiceLegacyEncryptorProvider", () => {
describe("userEncryptor$", () => {
it("emits a user key encryptor bound to the user", async () => {
const userKey$ = new BehaviorSubject<UserKey>(SomeUserKey);
keyService.userKey$.mockReturnValue(userKey$);
const singleUserId$ = new BehaviorSubject<UserId>(SomeUser);
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
const results: UserBound<"encryptor", UserEncryptor>[] = [];
provider.userEncryptor$(1, { singleUserId$ }).subscribe((v) => results.push(v));
expect(keyService.userKey$).toHaveBeenCalledWith(SomeUser);
expect(results.length).toBe(1);
expect(results[0]).toMatchObject({
userId: SomeUser,
encryptor: {
userId: SomeUser,
key: SomeUserKey,
dataPacker: { frameSize: 1 },
},
});
expect(results[0].encryptor).toBeInstanceOf(UserKeyEncryptor);
});
it("waits until `dependencies.singleUserId$` emits", () => {
const userKey$ = new BehaviorSubject<UserKey>(SomeUserKey);
keyService.userKey$.mockReturnValue(userKey$);
const singleUserId$ = new Subject<UserId>();
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
const results: UserBound<"encryptor", UserEncryptor>[] = [];
provider.userEncryptor$(1, { singleUserId$ }).subscribe((v) => results.push(v));
// precondition: no emissions occur on subscribe
expect(results.length).toBe(0);
singleUserId$.next(SomeUser);
expect(results.length).toBe(1);
});
it("emits a new user key encryptor each time `dependencies.singleUserId$` emits", () => {
const userKey$ = new BehaviorSubject<UserKey>(SomeUserKey);
keyService.userKey$.mockReturnValue(userKey$);
const singleUserId$ = new Subject<UserId>();
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
const results: UserBound<"encryptor", UserEncryptor>[] = [];
provider.userEncryptor$(1, { singleUserId$ }).subscribe((v) => results.push(v));
singleUserId$.next(SomeUser);
singleUserId$.next(SomeUser);
expect(results.length).toBe(2);
expect(results[0]).not.toBe(results[1]);
});
it("waits until `userKey$` emits a truthy value", () => {
const userKey$ = new BehaviorSubject<UserKey>(null);
keyService.userKey$.mockReturnValue(userKey$);
const singleUserId$ = new BehaviorSubject<UserId>(SomeUser);
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
const results: UserBound<"encryptor", UserEncryptor>[] = [];
provider.userEncryptor$(1, { singleUserId$ }).subscribe((v) => results.push(v));
// precondition: no emissions occur on subscribe
expect(results.length).toBe(0);
userKey$.next(SomeUserKey);
expect(results.length).toBe(1);
expect(results[0]).toMatchObject({
userId: SomeUser,
encryptor: {
userId: SomeUser,
key: SomeUserKey,
dataPacker: { frameSize: 1 },
},
});
});
it("emits a user key encryptor each time `userKey$` emits", () => {
const userKey$ = new Subject<UserKey>();
keyService.userKey$.mockReturnValue(userKey$);
const singleUserId$ = new BehaviorSubject<UserId>(SomeUser);
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
const results: UserBound<"encryptor", UserEncryptor>[] = [];
provider.userEncryptor$(1, { singleUserId$ }).subscribe((v) => results.push(v));
userKey$.next(SomeUserKey);
userKey$.next(SomeUserKey);
expect(results.length).toBe(2);
});
it("errors when the userId changes", () => {
const userKey$ = new BehaviorSubject<UserKey>(SomeUserKey);
keyService.userKey$.mockReturnValue(userKey$);
const singleUserId$ = new Subject<UserId>();
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
let error: unknown = false;
provider
.userEncryptor$(1, { singleUserId$ })
.subscribe({ error: (e: unknown) => (error = e) });
singleUserId$.next(SomeUser);
singleUserId$.next(AnotherUser);
expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: AnotherUser });
});
it("errors when `dependencies.singleUserId$` errors", () => {
const userKey$ = new BehaviorSubject<UserKey>(SomeUserKey);
keyService.userKey$.mockReturnValue(userKey$);
const singleUserId$ = new Subject<UserId>();
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
let error: unknown = false;
provider
.userEncryptor$(1, { singleUserId$ })
.subscribe({ error: (e: unknown) => (error = e) });
singleUserId$.error({ some: "error" });
expect(error).toEqual({ some: "error" });
});
it("errors once `dependencies.singleUserId$` emits and `userKey$` errors", () => {
const userKey$ = new Subject<UserKey>();
keyService.userKey$.mockReturnValue(userKey$);
const singleUserId$ = new BehaviorSubject<UserId>(SomeUser);
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
let error: unknown = false;
provider
.userEncryptor$(1, { singleUserId$ })
.subscribe({ error: (e: unknown) => (error = e) });
userKey$.error({ some: "error" });
expect(error).toEqual({ some: "error" });
});
it("completes when `dependencies.singleUserId$` completes", () => {
const userKey$ = new Subject<UserKey>();
keyService.userKey$.mockReturnValue(userKey$);
const singleUserId$ = new BehaviorSubject<UserId>(SomeUser);
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
let completed = false;
provider
.userEncryptor$(1, { singleUserId$ })
.subscribe({ complete: () => (completed = true) });
singleUserId$.complete();
expect(completed).toBeTrue();
});
it("completes when `userKey$` emits a falsy value after emitting a truthy value", () => {
const userKey$ = new BehaviorSubject<UserKey>(SomeUserKey);
keyService.userKey$.mockReturnValue(userKey$);
const singleUserId$ = new BehaviorSubject<UserId>(SomeUser);
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
let completed = false;
provider
.userEncryptor$(1, { singleUserId$ })
.subscribe({ complete: () => (completed = true) });
userKey$.next(null);
expect(completed).toBeTrue();
});
it("completes once `dependencies.singleUserId$` emits and `userKey$` completes", () => {
const userKey$ = new BehaviorSubject<UserKey>(SomeUserKey);
keyService.userKey$.mockReturnValue(userKey$);
const singleUserId$ = new BehaviorSubject<UserId>(SomeUser);
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
let completed = false;
provider
.userEncryptor$(1, { singleUserId$ })
.subscribe({ complete: () => (completed = true) });
userKey$.complete();
expect(completed).toBeTrue();
});
});
describe("organizationEncryptor$", () => {
it("emits an organization key encryptor bound to the organization", () => {
const orgKey$ = new BehaviorSubject(OrgRecords);
keyService.orgKeys$.mockReturnValue(orgKey$);
const singleOrganizationId$ = new BehaviorSubject<
UserBound<"organizationId", OrganizationId>
>({
organizationId: SomeOrganization,
userId: SomeUser,
});
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
const results: OrganizationBound<"encryptor", OrganizationEncryptor>[] = [];
provider
.organizationEncryptor$(1, { singleOrganizationId$ })
.subscribe((v) => results.push(v));
expect(keyService.orgKeys$).toHaveBeenCalledWith(SomeUser);
expect(results.length).toBe(1);
expect(results[0]).toMatchObject({
organizationId: SomeOrganization,
encryptor: {
organizationId: SomeOrganization,
key: SomeOrgKey,
dataPacker: { frameSize: 1 },
},
});
expect(results[0].encryptor).toBeInstanceOf(OrganizationKeyEncryptor);
});
it("waits until `dependencies.singleOrganizationId$` emits", () => {
const orgKey$ = new BehaviorSubject(OrgRecords);
keyService.orgKeys$.mockReturnValue(orgKey$);
const singleOrganizationId$ = new Subject<UserBound<"organizationId", OrganizationId>>();
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
const results: OrganizationBound<"encryptor", OrganizationEncryptor>[] = [];
provider
.organizationEncryptor$(1, { singleOrganizationId$ })
.subscribe((v) => results.push(v));
// precondition: no emissions occur on subscribe
expect(results.length).toBe(0);
singleOrganizationId$.next({
organizationId: SomeOrganization,
userId: SomeUser,
});
expect(results.length).toBe(1);
});
it("emits a new organization key encryptor when `dependencies.singleOrganizationId$` emits", () => {
const orgKey$ = new BehaviorSubject(OrgRecords);
keyService.orgKeys$.mockReturnValue(orgKey$);
const singleOrganizationId$ = new Subject<UserBound<"organizationId", OrganizationId>>();
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
const results: OrganizationBound<"encryptor", OrganizationEncryptor>[] = [];
provider
.organizationEncryptor$(1, { singleOrganizationId$ })
.subscribe((v) => results.push(v));
// precondition: no emissions occur on subscribe
expect(results.length).toBe(0);
singleOrganizationId$.next({
organizationId: SomeOrganization,
userId: SomeUser,
});
singleOrganizationId$.next({
organizationId: SomeOrganization,
userId: SomeUser,
});
expect(results.length).toBe(2);
expect(results[0]).not.toBe(results[1]);
});
it("waits until `orgKeys$` emits a truthy value", () => {
const orgKey$ = new BehaviorSubject<Record<OrganizationId, OrgKey>>(null);
keyService.orgKeys$.mockReturnValue(orgKey$);
const singleOrganizationId$ = new BehaviorSubject<
UserBound<"organizationId", OrganizationId>
>({
organizationId: SomeOrganization,
userId: SomeUser,
});
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
const results: OrganizationBound<"encryptor", OrganizationEncryptor>[] = [];
provider
.organizationEncryptor$(1, { singleOrganizationId$ })
.subscribe((v) => results.push(v));
// precondition: no emissions occur on subscribe
expect(results.length).toBe(0);
orgKey$.next(OrgRecords);
expect(results.length).toBe(1);
expect(results[0]).toMatchObject({
organizationId: SomeOrganization,
encryptor: {
organizationId: SomeOrganization,
key: SomeOrgKey,
dataPacker: { frameSize: 1 },
},
});
});
it("emits an organization key encryptor each time `orgKeys$` emits", () => {
const orgKey$ = new Subject<Record<OrganizationId, OrgKey>>();
keyService.orgKeys$.mockReturnValue(orgKey$);
const singleOrganizationId$ = new BehaviorSubject<
UserBound<"organizationId", OrganizationId>
>({
organizationId: SomeOrganization,
userId: SomeUser,
});
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
const results: OrganizationBound<"encryptor", OrganizationEncryptor>[] = [];
provider
.organizationEncryptor$(1, { singleOrganizationId$ })
.subscribe((v) => results.push(v));
orgKey$.next(OrgRecords);
orgKey$.next(OrgRecords);
expect(results.length).toBe(2);
});
it("errors when the userId changes", () => {
const orgKey$ = new BehaviorSubject(OrgRecords);
keyService.orgKeys$.mockReturnValue(orgKey$);
const singleOrganizationId$ = new Subject<UserBound<"organizationId", OrganizationId>>();
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
let error: unknown = false;
provider
.organizationEncryptor$(1, { singleOrganizationId$ })
.subscribe({ error: (e: unknown) => (error = e) });
singleOrganizationId$.next({ userId: SomeUser, organizationId: SomeOrganization });
singleOrganizationId$.next({ userId: AnotherUser, organizationId: SomeOrganization });
expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: AnotherUser });
});
it("errors when the organizationId changes", () => {
const orgKey$ = new BehaviorSubject(OrgRecords);
keyService.orgKeys$.mockReturnValue(orgKey$);
const singleOrganizationId$ = new Subject<UserBound<"organizationId", OrganizationId>>();
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
let error: unknown = false;
provider
.organizationEncryptor$(1, { singleOrganizationId$ })
.subscribe({ error: (e: unknown) => (error = e) });
singleOrganizationId$.next({ userId: SomeUser, organizationId: SomeOrganization });
singleOrganizationId$.next({ userId: SomeUser, organizationId: AnotherOrganization });
expect(error).toEqual({
expectedOrganizationId: SomeOrganization,
actualOrganizationId: AnotherOrganization,
});
});
it("errors when `dependencies.singleOrganizationId$` errors", () => {
const orgKey$ = new BehaviorSubject(OrgRecords);
keyService.orgKeys$.mockReturnValue(orgKey$);
const singleOrganizationId$ = new Subject<UserBound<"organizationId", OrganizationId>>();
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
let error: unknown = false;
provider
.organizationEncryptor$(1, { singleOrganizationId$ })
.subscribe({ error: (e: unknown) => (error = e) });
singleOrganizationId$.error({ some: "error" });
expect(error).toEqual({ some: "error" });
});
it("errors once `dependencies.singleOrganizationId$` emits and `orgKeys$` errors", () => {
const orgKey$ = new Subject<Record<OrganizationId, OrgKey>>();
keyService.orgKeys$.mockReturnValue(orgKey$);
const singleOrganizationId$ = new BehaviorSubject<
UserBound<"organizationId", OrganizationId>
>({
organizationId: SomeOrganization,
userId: SomeUser,
});
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
let error: unknown = false;
provider
.organizationEncryptor$(1, { singleOrganizationId$ })
.subscribe({ error: (e: unknown) => (error = e) });
orgKey$.error({ some: "error" });
expect(error).toEqual({ some: "error" });
});
it("errors when the user lacks the requested org key", () => {
const orgKey$ = new BehaviorSubject<Record<OrganizationId, OrgKey>>({});
keyService.orgKeys$.mockReturnValue(orgKey$);
const singleOrganizationId$ = new BehaviorSubject<
UserBound<"organizationId", OrganizationId>
>({
organizationId: SomeOrganization,
userId: SomeUser,
});
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
let error: unknown = false;
provider
.organizationEncryptor$(1, { singleOrganizationId$ })
.subscribe({ error: (e: unknown) => (error = e) });
expect(error).toBeInstanceOf(Error);
});
it("completes when `dependencies.singleOrganizationId$` completes", () => {
const orgKey$ = new BehaviorSubject(OrgRecords);
keyService.orgKeys$.mockReturnValue(orgKey$);
const singleOrganizationId$ = new Subject<UserBound<"organizationId", OrganizationId>>();
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
let completed = false;
provider
.organizationEncryptor$(1, { singleOrganizationId$ })
.subscribe({ complete: () => (completed = true) });
singleOrganizationId$.complete();
expect(completed).toBeTrue();
});
it("completes when `orgKeys$` emits a falsy value after emitting a truthy value", () => {
const orgKey$ = new Subject<Record<OrganizationId, OrgKey>>();
keyService.orgKeys$.mockReturnValue(orgKey$);
const singleOrganizationId$ = new BehaviorSubject<
UserBound<"organizationId", OrganizationId>
>({
organizationId: SomeOrganization,
userId: SomeUser,
});
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
let completed = false;
provider
.organizationEncryptor$(1, { singleOrganizationId$ })
.subscribe({ complete: () => (completed = true) });
orgKey$.next(OrgRecords);
orgKey$.next(null);
expect(completed).toBeTrue();
});
it("completes once `dependencies.singleOrganizationId$` emits and `userKey$` completes", () => {
const orgKey$ = new Subject<Record<OrganizationId, OrgKey>>();
keyService.orgKeys$.mockReturnValue(orgKey$);
const singleOrganizationId$ = new BehaviorSubject<
UserBound<"organizationId", OrganizationId>
>({
organizationId: SomeOrganization,
userId: SomeUser,
});
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
let completed = false;
provider
.organizationEncryptor$(1, { singleOrganizationId$ })
.subscribe({ complete: () => (completed = true) });
orgKey$.complete();
expect(completed).toBeTrue();
});
});
});

View File

@@ -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$;
}
}

View File

@@ -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>>;
}

View File

@@ -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>>;
}

View File

@@ -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);
});
});
});

View File

@@ -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`);
}
}
}

View File

@@ -6,8 +6,8 @@ import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypt
import { CsprngArray } from "../../types/csprng";
import { UserId } from "../../types/guid";
import { UserKey } from "../../types/key";
import { DataPacker } from "../state/data-packer.abstraction";
import { DataPacker } from "./data-packer.abstraction";
import { UserKeyEncryptor } from "./user-key-encryptor";
describe("UserKeyEncryptor", () => {

View File

@@ -5,8 +5,8 @@ import { UserId } from "@bitwarden/common/types/guid";
import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { EncString } from "../../platform/models/domain/enc-string";
import { UserKey } from "../../types/key";
import { DataPacker } from "../state/data-packer.abstraction";
import { DataPacker } from "./data-packer.abstraction";
import { UserEncryptor } from "./user-encryptor.abstraction";
/** A classification strategy that protects a type's secrets by encrypting them

View File

@@ -1,9 +1,10 @@
import { Observable } from "rxjs";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { UserId } from "@bitwarden/common/types/guid";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { UserEncryptor } from "./state/user-encryptor.abstraction";
import { OrganizationEncryptor } from "./cryptography/organization-encryptor.abstraction";
import { UserEncryptor } from "./cryptography/user-encryptor.abstraction";
/** error emitted when the `SingleUserDependency` changes Ids */
export type UserChangedError = {
@@ -13,6 +14,14 @@ export type UserChangedError = {
actualUserId: UserId;
};
/** error emitted when the `SingleOrganizationDependency` changes Ids */
export type OrganizationChangedError = {
/** the organizationId pinned by the single organization dependency */
expectedOrganizationId: OrganizationId;
/** the organizationId received in error */
actualOrganizationId: OrganizationId;
};
/** A pattern for types that depend upon a dynamic policy stream and return
* an observable.
*
@@ -55,6 +64,54 @@ export type UserBound<K extends keyof any, T> = { [P in K]: T } & {
userId: UserId;
};
/** Decorates a type to indicate the organization, if any, that the type is usable only by
* a specific organization.
*/
export type OrganizationBound<K extends keyof any, T> = { [P in K]: T } & {
/** The organization to which T is bound. */
organizationId: OrganizationId;
};
/** A pattern for types that depend upon a fixed-key encryptor and return
* an observable.
*
* Consumers of this dependency should emit a `OrganizationChangedError` if
* the bound OrganizationId changes or if the encryptor changes. If
* `singleOrganizationEncryptor$` completes, the consumer should complete
* once all events received prior to the completion event are
* finished processing. The consumer should, where possible,
* prioritize these events in order to complete as soon as possible.
* If `singleOrganizationEncryptor$` emits an unrecoverable error, the consumer
* should also emit the error.
*/
export type SingleOrganizationEncryptorDependency = {
/** A stream that emits an encryptor when subscribed and the org key
* is available, and completes when the org key is no longer available.
* The stream should not emit null or undefined.
*/
singleOrgEncryptor$: Observable<OrganizationBound<"encryptor", OrganizationEncryptor>>;
};
/** A pattern for types that depend upon a fixed-value organizationId and return
* an observable.
*
* Consumers of this dependency should emit a `OrganizationChangedError` if
* the value of `singleOrganizationId$` changes. If `singleOrganizationId$` completes,
* the consumer should also complete. If `singleOrganizationId$` errors, the
* consumer should also emit the error.
*
* @remarks Check the consumer's documentation to determine how it
* responds to repeat emissions.
*/
export type SingleOrganizationDependency = {
/** A stream that emits an organization Id and the user to which it is bound
* when subscribed and the user's account is unlocked, and completes when the
* account is locked or logged out.
* The stream should not emit null or undefined.
*/
singleOrganizationId$: Observable<UserBound<"organizationId", OrganizationId>>;
};
/** A pattern for types that depend upon a fixed-key encryptor and return
* an observable.
*

View File

@@ -8,6 +8,7 @@ import { awaitAsync, trackEmissions } from "../../spec";
import {
anyComplete,
errorOnChange,
distinctIfShallowMatch,
on,
ready,
@@ -15,6 +16,104 @@ import {
withLatestReady,
} from "./rx";
describe("errorOnChange", () => {
it("emits a single value when the input emits only once", async () => {
const source$ = new Subject<number>();
const results: number[] = [];
source$.pipe(errorOnChange()).subscribe((v) => results.push(v));
source$.next(1);
expect(results).toEqual([1]);
});
it("emits when the input emits", async () => {
const source$ = new Subject<number>();
const results: number[] = [];
source$.pipe(errorOnChange()).subscribe((v) => results.push(v));
source$.next(1);
source$.next(1);
expect(results).toEqual([1, 1]);
});
it("errors when the input errors", async () => {
const source$ = new Subject<number>();
const expected = {};
let error: any = null;
source$.pipe(errorOnChange()).subscribe({ error: (v: unknown) => (error = v) });
source$.error(expected);
expect(error).toBe(expected);
});
it("completes when the input completes", async () => {
const source$ = new Subject<number>();
let complete: boolean = false;
source$.pipe(errorOnChange()).subscribe({ complete: () => (complete = true) });
source$.complete();
expect(complete).toBeTrue();
});
it("errors when the input changes", async () => {
const source$ = new Subject<number>();
let error: any = null;
source$.pipe(errorOnChange()).subscribe({ error: (v: unknown) => (error = v) });
source$.next(1);
source$.next(2);
expect(error).toEqual({ expectedValue: 1, actualValue: 2 });
});
it("emits when the extracted value remains constant", async () => {
type Foo = { foo: string };
const source$ = new Subject<Foo>();
const results: Foo[] = [];
source$.pipe(errorOnChange((v) => v.foo)).subscribe((v) => results.push(v));
source$.next({ foo: "bar" });
source$.next({ foo: "bar" });
expect(results).toEqual([{ foo: "bar" }, { foo: "bar" }]);
});
it("errors when an extracted value changes", async () => {
type Foo = { foo: string };
const source$ = new Subject<Foo>();
let error: any = null;
source$.pipe(errorOnChange((v) => v.foo)).subscribe({ error: (v: unknown) => (error = v) });
source$.next({ foo: "bar" });
source$.next({ foo: "baz" });
expect(error).toEqual({ expectedValue: "bar", actualValue: "baz" });
});
it("constructs an error when the extracted value changes", async () => {
type Foo = { foo: string };
const source$ = new Subject<Foo>();
let error: any = null;
source$
.pipe(
errorOnChange(
(v) => v.foo,
(expected, actual) => ({ expected, actual }),
),
)
.subscribe({ error: (v: unknown) => (error = v) });
source$.next({ foo: "bar" });
source$.next({ foo: "baz" });
expect(error).toEqual({ expected: "bar", actual: "baz" });
});
});
describe("reduceCollection", () => {
it.each([[null], [undefined], [[]]])(
"should return the default value when the collection is %p",

View File

@@ -15,8 +15,60 @@ import {
takeUntil,
withLatestFrom,
concatMap,
startWith,
pairwise,
} from "rxjs";
/** Returns its input. */
function identity(value: any): any {
return value;
}
/** Combines its arguments into a plain old javascript object. */
function expectedAndActualValue(expectedValue: any, actualValue: any) {
return {
expectedValue,
actualValue,
};
}
/**
* An observable operator that throws an error when the stream's
* value changes. Uses strict (`===`) comparison checks.
* @param extract a function that identifies the member to compare;
* defaults to the identity function
* @param error a function that packages the expected and failed
* values into an error.
* @returns a stream of values that emits when the input emits,
* completes when the input completes, and errors when either the
* input errors or the comparison fails.
*/
export function errorOnChange<Input, Extracted>(
extract: (value: Input) => Extracted = identity,
error: (expectedValue: Extracted, actualValue: Extracted) => unknown = expectedAndActualValue,
): OperatorFunction<Input, Input> {
return pipe(
startWith(null),
pairwise(),
map(([expected, actual], i) => {
// always let the first value through
if (i === 0) {
return actual;
}
const expectedValue = extract(expected);
const actualValue = extract(actual);
// fail the stream if the state desyncs from its initial value
if (expectedValue === actualValue) {
return actual;
} else {
throw error(expectedValue, actualValue);
}
}),
);
}
/**
* An observable operator that reduces an emitted collection to a single object,
* returning a default if all items are ignored.

View File

@@ -5,6 +5,17 @@ import type { StateDefinition } from "../../platform/state/state-definition";
import { ClassifiedFormat } from "./classified-format";
import { Classifier } from "./classifier";
/** Determines the format of persistent storage.
* `plain` storage is a plain-old javascript object. Use this type
* when you are performing your own encryption and decryption.
* `classified` uses the `ClassifiedFormat` type as its format.
* `secret-state` uses `Array<ClassifiedFormat>` with a length of 1.
* @remarks - CAUTION! If your on-disk data is not in a correct format,
* the storage system treats the data as corrupt and returns your initial
* value.
*/
export type ObjectStorageFormat = "plain" | "classified" | "secret-state";
/** A key for storing JavaScript objects (`{ an: "example" }`)
* in a UserStateSubject.
*/
@@ -20,7 +31,7 @@ export type ObjectKey<State, Secret = State, Disclosed = Record<string, never>>
key: string;
state: StateDefinition;
classifier: Classifier<State, Disclosed, Secret>;
format: "plain" | "classified";
format: ObjectStorageFormat;
options: UserKeyDefinitionOptions<State>;
initial?: State;
};
@@ -47,6 +58,18 @@ export function toUserKeyDefinition<State, Secret, Disclosed>(
},
);
return classified;
} else if (key.format === "secret-state") {
const classified = new UserKeyDefinition<[ClassifiedFormat<void, Disclosed>]>(
key.state,
key.key,
{
cleanupDelayMs: key.options.cleanupDelayMs,
deserializer: (jsonValue) => jsonValue as [ClassifiedFormat<void, Disclosed>],
clearOn: key.options.clearOn,
},
);
return classified;
} else {
throw new Error(`unknown format: ${key.format}`);

View File

@@ -11,11 +11,11 @@ import {
import { EncString } from "../../platform/models/domain/enc-string";
import { GENERATOR_DISK } from "../../platform/state";
import { UserId } from "../../types/guid";
import { UserEncryptor } from "../cryptography/user-encryptor.abstraction";
import { SecretClassifier } from "./secret-classifier";
import { SecretKeyDefinition } from "./secret-key-definition";
import { SecretState } from "./secret-state";
import { UserEncryptor } from "./user-encryptor.abstraction";
type FooBar = { foo: boolean; bar: boolean; date?: Date };
const classifier = SecretClassifier.allSecret<FooBar>();

View File

@@ -8,10 +8,10 @@ import {
CombinedState,
} from "../../platform/state";
import { UserId } from "../../types/guid";
import { UserEncryptor } from "../cryptography/user-encryptor.abstraction";
import { ClassifiedFormat } from "./classified-format";
import { SecretKeyDefinition } from "./secret-key-definition";
import { UserEncryptor } from "./user-encryptor.abstraction";
const ONE_MINUTE = 1000 * 60;

View File

@@ -4,13 +4,13 @@ import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/st
import { UserId } from "@bitwarden/common/types/guid";
import { awaitAsync, FakeSingleUserState, ObservableTracker } from "../../../spec";
import { UserEncryptor } from "../cryptography/user-encryptor.abstraction";
import { UserBound } from "../dependencies";
import { PrivateClassifier } from "../private-classifier";
import { StateConstraints } from "../types";
import { ClassifiedFormat } from "./classified-format";
import { ObjectKey } from "./object-key";
import { UserEncryptor } from "./user-encryptor.abstraction";
import { UserStateSubject } from "./user-state-subject";
const SomeUser = "some user" as UserId;
@@ -734,6 +734,7 @@ describe("UserStateSubject", () => {
error = e as any;
},
});
singleUserEncryptor$.next({ userId: SomeUser, encryptor: SomeEncryptor });
singleUserEncryptor$.next({ userId: errorUserId, encryptor: SomeEncryptor });
await awaitAsync();

View File

@@ -6,10 +6,8 @@ import {
filter,
map,
takeUntil,
pairwise,
distinctUntilChanged,
BehaviorSubject,
startWith,
Observable,
Subscription,
last,
@@ -30,15 +28,15 @@ import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SingleUserState, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { UserEncryptor } from "../cryptography/user-encryptor.abstraction";
import { UserBound } from "../dependencies";
import { anyComplete, ready, withLatestReady } from "../rx";
import { anyComplete, errorOnChange, ready, withLatestReady } from "../rx";
import { Constraints, SubjectConstraints, WithConstraints } from "../types";
import { ClassifiedFormat, isClassifiedFormat } from "./classified-format";
import { unconstrained$ } from "./identity-state-constraint";
import { isObjectKey, ObjectKey, toUserKeyDefinition } from "./object-key";
import { isDynamic } from "./state-constraints-dependency";
import { UserEncryptor } from "./user-encryptor.abstraction";
import { UserStateSubjectDependencies } from "./user-state-subject-dependencies";
type Constrained<State> = { constraints: Readonly<Constraints<State>>; state: State };
@@ -195,24 +193,13 @@ export class UserStateSubject<
}
}),
// fail the stream if the state desyncs from the bound userId
startWith({ userId: this.state.userId, encryptor: null } as UserBound<
"encryptor",
UserEncryptor
>),
pairwise(),
map(([expected, actual]) => {
if (expected.userId === actual.userId) {
return actual;
} else {
throw {
expectedUserId: expected.userId,
actualUserId: actual.userId,
};
}
}),
errorOnChange(
({ userId }) => userId,
(expectedUserId, actualUserId) => ({ expectedUserId, actualUserId }),
),
// reduce emissions to when encryptor changes
distinctUntilChanged(),
map(({ encryptor }) => encryptor),
distinctUntilChanged(),
);
}
@@ -317,36 +304,63 @@ export class UserStateSubject<
return (input$) => input$ as Observable<State>;
}
// if the key supports encryption, enable encryptor support
// all other keys support encryption; enable encryptor support
return pipe(
this.mapToClassifiedFormat(),
combineLatestWith(encryptor$),
concatMap(async ([input, encryptor]) => {
// pass through null values
if (input === null || input === undefined) {
return null;
}
// decrypt classified data
const { secret, disclosed } = input;
const encrypted = EncString.fromJSON(secret);
const decryptedSecret = await encryptor.decrypt<Secret>(encrypted);
// assemble into proper state
const declassified = this.objectKey.classifier.declassify(disclosed, decryptedSecret);
const state = this.objectKey.options.deserializer(declassified);
return state;
}),
);
}
private mapToClassifiedFormat(): OperatorFunction<unknown, ClassifiedFormat<unknown, unknown>> {
// FIXME: warn when data is dropped in the console and/or report an error
// through the observable; consider redirecting dropped data to a recovery
// location
// user-state subject's default format is object-aware
if (this.objectKey && this.objectKey.format === "classified") {
return pipe(
combineLatestWith(encryptor$),
concatMap(async ([input, encryptor]) => {
// pass through null values
if (input === null || input === undefined) {
return null;
}
return map((input) => {
if (!isClassifiedFormat(input)) {
return null;
}
// fail fast if the format is incorrect
if (!isClassifiedFormat(input)) {
throw new Error(`Cannot declassify ${this.key.key}; unknown format.`);
}
// decrypt classified data
const { secret, disclosed } = input;
const encrypted = EncString.fromJSON(secret);
const decryptedSecret = await encryptor.decrypt<Secret>(encrypted);
// assemble into proper state
const declassified = this.objectKey.classifier.declassify(disclosed, decryptedSecret);
const state = this.objectKey.options.deserializer(declassified);
return state;
}),
);
return input;
});
}
throw new Error(`unknown serialization format: ${this.objectKey.format}`);
// secret state's format wraps objects in an array
if (this.objectKey && this.objectKey.format === "secret-state") {
return map((input) => {
if (!Array.isArray(input)) {
return null;
}
const [unwrapped] = input;
if (!isClassifiedFormat(unwrapped)) {
return null;
}
return unwrapped;
});
}
throw new Error(`unsupported serialization format: ${this.objectKey.format}`);
}
private classify(encryptor$: Observable<UserEncryptor>): OperatorFunction<State, unknown> {
@@ -359,41 +373,49 @@ export class UserStateSubject<
);
}
// if the key supports encryption, enable encryptor support
// all other keys support encryption; enable encryptor support
return pipe(
withLatestReady(encryptor$),
concatMap(async ([input, encryptor]) => {
// fail fast if there's no value
if (input === null || input === undefined) {
return null;
}
// split data by classification level
const serialized = JSON.parse(JSON.stringify(input));
const classified = this.objectKey.classifier.classify(serialized);
// protect data
const encrypted = await encryptor.encrypt(classified.secret);
const secret = JSON.parse(JSON.stringify(encrypted));
// wrap result in classified format envelope for storage
const envelope = {
id: null as void,
secret,
disclosed: classified.disclosed,
} satisfies ClassifiedFormat<void, Disclosed>;
// deliberate type erasure; the type is restored during `declassify`
return envelope as ClassifiedFormat<unknown, unknown>;
}),
this.mapToStorageFormat(),
);
}
private mapToStorageFormat(): OperatorFunction<ClassifiedFormat<unknown, unknown>, unknown> {
// user-state subject's default format is object-aware
if (this.objectKey && this.objectKey.format === "classified") {
return pipe(
withLatestReady(encryptor$),
concatMap(async ([input, encryptor]) => {
// fail fast if there's no value
if (input === null || input === undefined) {
return null;
}
// split data by classification level
const serialized = JSON.parse(JSON.stringify(input));
const classified = this.objectKey.classifier.classify(serialized);
// protect data
const encrypted = await encryptor.encrypt(classified.secret);
const secret = JSON.parse(JSON.stringify(encrypted));
// wrap result in classified format envelope for storage
const envelope = {
id: null as void,
secret,
disclosed: classified.disclosed,
} satisfies ClassifiedFormat<void, Disclosed>;
// deliberate type erasure; the type is restored during `declassify`
return envelope as unknown;
}),
);
return map((input) => input as unknown);
}
// FIXME: add "encrypted" format --> key contains encryption logic
// CONSIDER: should "classified format" algorithm be embedded in subject keys...?
// secret state's format wraps objects in an array
if (this.objectKey && this.objectKey.format === "secret-state") {
return map((input) => [input] as unknown);
}
throw new Error(`unknown serialization format: ${this.objectKey.format}`);
throw new Error(`unsupported serialization format: ${this.objectKey.format}`);
}
/** The userId to which the subject is bound.