diff --git a/src/settings-manager/encrypted-metadata-manager.test.ts b/src/settings-manager/encrypted-metadata-manager.test.ts new file mode 100644 index 0000000..32a198f --- /dev/null +++ b/src/settings-manager/encrypted-metadata-manager.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + DecryptCallback, + EncryptCallback, + EncryptedMetadataManager, +} from "./encrypted-metadata-manager"; +import { MetadataEntry } from "./metadata-manager"; + +const initialEntry = { key: "a", value: "without domain" }; +const encryptedEntry = { + key: "encrypted", + value: "ddd891446804916e3b2cba1fad9f4ebf9643e1a56794e8a9", +}; // the value is encrypted string `new value` + +const entryForDomainX = { key: "a__x.com", value: "domain x value" }; +const entryForDomainY = { key: "a__y.com", value: "domain y value" }; + +const metadata = [initialEntry, entryForDomainX, entryForDomainY, encryptedEntry]; + +describe("settings-manager", () => { + describe("metadata-manager", () => { + const fetchMock = vi.fn(async () => metadata); + const mutateMock = vi.fn(async (md: MetadataEntry[]) => [...metadata, ...md]); + const manager = new EncryptedMetadataManager({ + fetchMetadata: fetchMock, + mutateMetadata: mutateMock, + encryptionKey: "key", + }); + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("Set encrypted value in the metadata", async () => { + const newEntry = { key: "new", value: "new value" }; + + await manager.set(newEntry); + const mutateValue = mutateMock.mock.lastCall![0][0].value; + + // Encrypted value should be encrypted, alphanumeric value and different than input + expect(mutateValue).toMatch(/^[\d\w]+$/); + expect(mutateValue).not.toEqual(newEntry.key); + // Set method should populate cache with updated values, so fetch is never called + expect(fetchMock).toBeCalledTimes(0); + }); + + it("Get encrypted data from metadata", async () => { + const value = await manager.get(encryptedEntry.key); + expect(value).toMatch("new value"); + // make sure encrypted metadata is different than decrypted value + expect(value).not.toEqual(encryptedEntry.value); + expect(fetchMock).toBeCalledTimes(0); + }); + + it("Use custom encryption callbacks", async () => { + const newEntry = { key: "new", value: "new value" }; + + // dummy encryption - join value and string together + const customEncrypt: EncryptCallback = (value, secret) => value + secret; + // dummy decryption - remove secret from end of the "encrypted" value + const customDecrypt: DecryptCallback = (value, secret) => + value.substr(0, value.length - secret.length); + + const customManager = new EncryptedMetadataManager({ + fetchMetadata: fetchMock, + mutateMetadata: mutateMock, + encryptionKey: "key", + encryptionMethod: customEncrypt, + decryptionMethod: customDecrypt, + }); + + await customManager.set(newEntry); + // value send to the API should be encrypted with custom method + const mutateValue = mutateMock.mock.lastCall![0][0].value; + expect(mutateValue).toMatch("new valuekey"); + + // value from get should be "decrypted" using custom method + expect(await customManager.get(newEntry.key)).toMatch("new value"); + // Set method should populate cache with updated values, so fetch is never called + expect(fetchMock).toBeCalledTimes(0); + }); + }); +}); diff --git a/src/settings-manager/encrypted-metadata-manager.ts b/src/settings-manager/encrypted-metadata-manager.ts new file mode 100644 index 0000000..9166ede --- /dev/null +++ b/src/settings-manager/encrypted-metadata-manager.ts @@ -0,0 +1,98 @@ +import crypto from "crypto"; + +import { MetadataManager, MetadataManagerConfig } from "./metadata-manager"; +import { SettingsManager, SettingsValue } from "./settings-manager"; + +export type EncryptCallback = (value: string, secret: string) => string; + +export type DecryptCallback = (value: string, secret: string) => string; + +/** + * Ensures key has constant length of 32 characters + */ +const prepareKey = (key: string) => + crypto.createHash("sha256").update(String(key)).digest("base64").substr(0, 32); + +/** + * Encrypt string using AES-256 + */ +export const encrypt = (data: string, key: string) => { + const iv = crypto.randomBytes(16).toString("hex").slice(0, 16); + const cipher = crypto.createCipheriv("aes256", prepareKey(key), iv); + let encrypted = cipher.update(data, "utf8", "hex"); + encrypted += cipher.final("hex"); + + return `${iv}${encrypted}`; +}; + +/** + * Decrypt string encrypted with AES-256 + */ +export const decrypt = (data: string, key: string) => { + const [iv, encrypted] = [data.slice(0, 16), data.slice(16)]; + const decipher = crypto.createDecipheriv("aes256", prepareKey(key), iv); + let message = decipher.update(encrypted, "hex", "utf8"); + message += decipher.final("utf8"); + + return message; +}; + +interface EncryptedMetadataManagerConfig extends MetadataManagerConfig { + encryptionKey: string; + encryptionMethod?: EncryptCallback; + decryptionMethod?: DecryptCallback; +} + +/** + * Encrypted Metadata Manager use app metadata to store settings. + * To minimize network calls, once fetched metadata are cached. + * Cache invalidation occurs if any value is set. + * + * By default data encryption use AES-256 algorithm. If you want to use a different + * method, provide `encryptionMethod` and `decryptionMethod`. + */ +export class EncryptedMetadataManager implements SettingsManager { + private encryptionKey: string; + + private encryptionMethod: EncryptCallback; + + private decryptionMethod: DecryptCallback; + + private metadataManager: MetadataManager; + + constructor({ + fetchMetadata, + mutateMetadata, + encryptionKey, + encryptionMethod, + decryptionMethod, + }: EncryptedMetadataManagerConfig) { + this.metadataManager = new MetadataManager({ + fetchMetadata, + mutateMetadata, + }); + this.encryptionKey = encryptionKey; + this.encryptionMethod = encryptionMethod || encrypt; + this.decryptionMethod = decryptionMethod || decrypt; + } + + async get(key: string, domain?: string) { + const encryptedValue = await this.metadataManager.get(key, domain); + if (!encryptedValue) { + return undefined; + } + return this.decryptionMethod(encryptedValue, this.encryptionKey); + } + + async set(settings: SettingsValue[] | SettingsValue) { + if (!Array.isArray(settings)) { + const encryptedValue = this.encryptionMethod(settings.value, this.encryptionKey); + return this.metadataManager.set({ ...settings, value: encryptedValue }); + } + const encryptedSettings = settings.map((s) => ({ + ...s, + value: this.encryptionMethod(s.value, this.encryptionKey), + })); + return this.metadataManager.set(encryptedSettings); + } +} diff --git a/src/settings-manager/index.ts b/src/settings-manager/index.ts index 494eaa5..9c35b46 100644 --- a/src/settings-manager/index.ts +++ b/src/settings-manager/index.ts @@ -1,2 +1,3 @@ +export * from "./encrypted-metadata-manager"; export * from "./metadata-manager"; export * from "./settings-manager"; diff --git a/src/settings-manager/metadata-manager.ts b/src/settings-manager/metadata-manager.ts index 1ee498b..0339f11 100644 --- a/src/settings-manager/metadata-manager.ts +++ b/src/settings-manager/metadata-manager.ts @@ -32,7 +32,7 @@ const serializeSettingsToMetadata = ({ key, value, domain }: SettingsValue): Met }; }; -interface MetadataManagerConfig { +export interface MetadataManagerConfig { fetchMetadata: FetchMetadataCallback; mutateMetadata: MutateMetadataCallback; }