Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TDL-11] Proposal - item decryption refactor (alternative proposal) #3738

Closed
wants to merge 20 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions libs/common/spec/misc/encrypted.decorator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { encrypted, getEncryptedProperties } from "@bitwarden/common/misc/encrypted.decorator";

class TestClass {
@encrypted
encryptedString: string;
@encrypted
anotherEncryptedString: string;

someOtherProperty: Date;
}

describe("encrypted decorator", () => {
it("adds property name to list", () => {
const testClass = new TestClass();
const result = getEncryptedProperties(testClass);
expect(result).toEqual(["encryptedString", "anotherEncryptedString"]);
});
});
103 changes: 103 additions & 0 deletions libs/common/spec/services/encrypt.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ import { EncryptService } from "@bitwarden/common/services/encrypt.service";

import { makeStaticByteArray } from "../utils";

import {
NestedArrayEncryptedObject,
NestedArrayEncryptedObjectView,
NestedEncryptedObject,
NestedEncryptedObjectView,
SimpleEncryptedObject,
SimpleEncryptedObjectView,
} from "./encrypted-objects";

describe("EncryptService", () => {
const cryptoFunctionService = mock<CryptoFunctionService>();
const logService = mock<LogService>();
Expand Down Expand Up @@ -185,4 +194,98 @@ describe("EncryptService", () => {
expect(actual).toEqual(key);
});
});

describe("decryptItem", () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe("decrypts", () => {
beforeEach(() => {
jest
.spyOn(encryptService, "decryptToUtf8")
.mockImplementation((encString, key) =>
Promise.resolve(encString.data.replace("_Encrypted", ""))
);
});

it("encStrings", async () => {
const target = new SimpleEncryptedObject(1);

const result = await encryptService.decryptItem(target, mock<SymmetricCryptoKey>());

expect(result).toMatchObject({
id: 1,
username: "myUsername1",
password: "myPassword1",
});
expect(result).toBeInstanceOf(SimpleEncryptedObjectView);
});

it("nested IDecryptables", async () => {
const target = new NestedEncryptedObject();

const result = await encryptService.decryptItem(target, mock<SymmetricCryptoKey>());

expect(result).toMatchObject({
nestedLogin1: {
id: 1,
username: "myUsername1",
password: "myPassword1",
},
nestedLogin2: {
id: 2,
username: "myUsername2",
password: "myPassword2",
},
collectionId: "myCollectionId",
});
expect(result).toBeInstanceOf(NestedEncryptedObjectView);
expect(result.nestedLogin1).toBeInstanceOf(SimpleEncryptedObjectView);
expect(result.nestedLogin2).toBeInstanceOf(SimpleEncryptedObjectView);
});

it("an array of nested IDecryptables", async () => {
const target = new NestedArrayEncryptedObject();

const result = await encryptService.decryptItem(target, mock<SymmetricCryptoKey>());

expect(result).toMatchObject({
logins: [
{
id: 1,
username: "myUsername1",
password: "myPassword1",
},
{
id: 2,
username: "myUsername2",
password: "myPassword2",
},
],
collectionId: "myCollectionId",
});

expect(result).toBeInstanceOf(NestedArrayEncryptedObjectView);
expect(result.logins[0]).toBeInstanceOf(SimpleEncryptedObjectView);
expect(result.logins[1]).toBeInstanceOf(SimpleEncryptedObjectView);
});
});

it("handles decryption errors", async () => {
const target = new SimpleEncryptedObject(1);
jest.spyOn(encryptService, "decryptToUtf8").mockRejectedValue("some decryption error");

const result = await encryptService.decryptItem(target, mock<SymmetricCryptoKey>());

const decryptionError = "[error: cannot decrypt]";

expect(result).toMatchObject({
id: 1,
username: decryptionError,
password: decryptionError,
});
expect(result).toBeInstanceOf(SimpleEncryptedObjectView);
});
});
});
73 changes: 73 additions & 0 deletions libs/common/spec/services/encrypted-objects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Decryptable } from "../../src/interfaces/decryptable.interface";
import { encrypted } from "../../src/misc/encrypted.decorator";
import { EncString } from "../../src/models/domain/encString";

export class SimpleEncryptedObjectView {
id: number;
username: string;
password: string;
}

/**
* An object with EncStrings and non-encrypted fields.
*/
export class SimpleEncryptedObject implements Decryptable<SimpleEncryptedObjectView> {
@encrypted username = new EncString("3.myUsername" + this.id + "_Encrypted");
@encrypted password = new EncString("3.myPassword" + this.id + "_Encrypted");

constructor(public id: number) {}

toView(decryptedProperties: any) {
return Object.assign(
new SimpleEncryptedObjectView(),
{
// Manually copy over unencrypted values
id: this.id,
},
decryptedProperties // Assign everything else from the decryptedProperties object
);
Comment on lines +20 to +28
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't particularly like how this turned out of any of the examples.

For one, the decrypted properties is an any rather than some inferred type (think Jsonify magic).

Second, it assumes that decrypted properties are going to be named the same in the View layer.

I think some work needs to be done to improve this interface a bit before it's ready to go.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this was a bit of a hack to illustrate the idea of an intermediate "decrypted properties" object, as a way to get decrypted values to the View without caching them on Domain. It can be improved for sure.

I wasn't sure whether mapping property values to different properties on the View object was actually required. We have that capability at the moment and I don't think we actually use it anywhere. But it's probably best to keep it for flexibility, especially if we're considering having multiple Views.

Typing this correctly was a TODO, in theory we should be able to have a mapped type like:

  • EncStrings become strings
  • Domain becomes the corresponding View (?)
  • everything else stays the same

So I can definitely experiment with that further. As you say, jsonify magic.

}
}

export class NestedEncryptedObjectView {}

/**
* An object with nested encrypted objects (i.e. objects containing EncStrings)
*/
export class NestedEncryptedObject implements Decryptable<NestedEncryptedObjectView> {
@encrypted nestedLogin1 = new SimpleEncryptedObject(1);
@encrypted nestedLogin2 = new SimpleEncryptedObject(2);
collectionId = "myCollectionId";

toView(decryptedProperties: any) {
return Object.assign(
new NestedEncryptedObjectView(),
{
// Manually copy over unencrypted values
collectionId: this.collectionId,
},
decryptedProperties // Assign everything else from the decryptedProperties object
);
}
}

export class NestedArrayEncryptedObjectView {}

/**
* An object with nested encrypted objects (i.e. objects containing EncStrings) in an array
*/
export class NestedArrayEncryptedObject implements Decryptable<NestedArrayEncryptedObjectView> {
@encrypted logins = [new SimpleEncryptedObject(1), new SimpleEncryptedObject(2)];
collectionId = "myCollectionId";

toView(decryptedProperties: any) {
return Object.assign(
new NestedArrayEncryptedObjectView(),
{
// Manually copy over unencrypted values
collectionId: this.collectionId,
},
decryptedProperties // Assign everything else from the decryptedProperties object
);
}
}
2 changes: 2 additions & 0 deletions libs/common/src/abstractions/abstractEncrypt.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { IEncrypted } from "../interfaces/IEncrypted";
import { Decryptable } from "../interfaces/decryptable.interface";
import { EncArrayBuffer } from "../models/domain/encArrayBuffer";
import { EncString } from "../models/domain/encString";
import { SymmetricCryptoKey } from "../models/domain/symmetricCryptoKey";
Expand All @@ -12,4 +13,5 @@ export abstract class AbstractEncryptService {
abstract decryptToUtf8: (encString: EncString, key: SymmetricCryptoKey) => Promise<string>;
abstract decryptToBytes: (encThing: IEncrypted, key: SymmetricCryptoKey) => Promise<ArrayBuffer>;
abstract resolveLegacyKey: (key: SymmetricCryptoKey, encThing: IEncrypted) => SymmetricCryptoKey;
abstract decryptItem: <T>(item: Decryptable<T>, key: SymmetricCryptoKey) => Promise<T>;
}
3 changes: 3 additions & 0 deletions libs/common/src/interfaces/decryptable.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface Decryptable<T> {
toView: (decryptedProperties: any) => T;
}
Comment on lines +1 to +3
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's a good idea to convert this to a fromDomain and come up with a new name for the interface, that way we could have a single Domain hydrate multiple views for different purposes, as Oscar proposed.

Copy link
Member Author

@eliykat eliykat Oct 12, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fine with that, but fromDomain would be a static factory method, right? Any ideas on how to enforce that interface when it's static? Or would we hand the factory method explicitly to the decryptItem call like

decryptItem<TEncrypted, TDecrypted>(item: TEncrypted, key: SymmetricCryptoKey, viewFactory: (props: DecryptedProperties<TEncrypted>) => TDecrypted): TDecrypted

assuming that DecryptedProperties<TEncrypted> is the mapped type for the decrypted props.

16 changes: 16 additions & 0 deletions libs/common/src/misc/encrypted.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import "reflect-metadata"; // TODO: this can only be imported in one place, so shouldn't be here

const metadataKey = Symbol("encryptedPropertiesKey");

export function encrypted(prototype: any, propertyKey: string) {
const encStringList: string[] = Reflect.getMetadata(metadataKey, prototype);
if (encStringList == null) {
Reflect.defineMetadata(metadataKey, [propertyKey], prototype);
} else {
encStringList.push(propertyKey);
}
}

export function getEncryptedProperties(target: any): string[] {
return Reflect.getMetadata(metadataKey, target);
}
6 changes: 5 additions & 1 deletion libs/common/src/models/domain/encString.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ import { SymmetricCryptoKey } from "./symmetricCryptoKey";
export class EncString implements IEncrypted {
encryptedString?: string;
encryptionType?: EncryptionType;
decryptedValue?: string;
data?: string;
iv?: string;
mac?: string;

/**
* @deprecated: EncStrings no longer store their decrypted values
*/
private decryptedValue?: string;

constructor(
encryptedStringOrType: string | EncryptionType,
data?: string,
Expand Down
58 changes: 58 additions & 0 deletions libs/common/src/services/encrypt.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import { CryptoFunctionService } from "../abstractions/cryptoFunction.service";
import { LogService } from "../abstractions/log.service";
import { EncryptionType } from "../enums/encryptionType";
import { IEncrypted } from "../interfaces/IEncrypted";
import { Decryptable } from "../interfaces/decryptable.interface";
import { getEncryptedProperties } from "../misc/encrypted.decorator";
import { Utils } from "../misc/utils";
import { EncArrayBuffer } from "../models/domain/encArrayBuffer";
import { EncString } from "../models/domain/encString";
import { EncryptedObject } from "../models/domain/encryptedObject";
import { SymmetricCryptoKey } from "../models/domain/symmetricCryptoKey";
import { View } from "../models/view/view";

export class EncryptService implements AbstractEncryptService {
constructor(
Expand Down Expand Up @@ -164,6 +167,61 @@ export class EncryptService implements AbstractEncryptService {
return obj;
}

async decryptItem<T>(item: Decryptable<T>, key: SymmetricCryptoKey): Promise<T> {
if (item == null) {
throw new Error("Cannot decrypt a null item");
}

const promises: Promise<any>[] = [];
const decryptedValues: Record<string, string | View | Array<View>> = {};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably BufferArray is needed here, too.


const encryptedProperties = getEncryptedProperties(item);
encryptedProperties?.forEach((prop) => {
const propValue = (item as any)[prop];
if (propValue == null) {
return;
}

// Case 1: the property is an EncString
if (propValue instanceof EncString) {
promises.push(
this.decryptToUtf8(propValue, key)
.then((decryptedValue) => (decryptedValues[prop] = decryptedValue))
.catch((e) => {
this.logService.error(e);
decryptedValues[prop] = "[error: cannot decrypt]";
})
);
return;
}

// Case 2: the property is an array of nested decryptables
// NB: arrays of EncStrings are not supported at this time (no current use cases)
if (propValue instanceof Array) {
decryptedValues[prop] = [];

propValue.forEach((subItem) =>
promises.push(
this.decryptItem(subItem, key).then((decryptedValue) =>
(decryptedValues[prop] as Array<View>).push(decryptedValue)
)
)
);
} else {
// Case 3: the property is a single nested decryptable
promises.push(
this.decryptItem(propValue, key).then(
(decryptedValue) => (decryptedValues[prop] = decryptedValue)
)
);
}
});

await Promise.all(promises);

return item.toView(decryptedValues);
}

private logMacFailed(msg: string) {
if (this.logMacFailures) {
this.logService.error(msg);
Expand Down
24 changes: 20 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading