Add encrypted metadata manager (#84)
This commit is contained in:
parent
aac0b42c6e
commit
6fb103cb07
4 changed files with 184 additions and 1 deletions
84
src/settings-manager/encrypted-metadata-manager.test.ts
Normal file
84
src/settings-manager/encrypted-metadata-manager.test.ts
Normal file
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
98
src/settings-manager/encrypted-metadata-manager.ts
Normal file
98
src/settings-manager/encrypted-metadata-manager.ts
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -1,2 +1,3 @@
|
|||
export * from "./encrypted-metadata-manager";
|
||||
export * from "./metadata-manager";
|
||||
export * from "./settings-manager";
|
||||
|
|
|
@ -32,7 +32,7 @@ const serializeSettingsToMetadata = ({ key, value, domain }: SettingsValue): Met
|
|||
};
|
||||
};
|
||||
|
||||
interface MetadataManagerConfig {
|
||||
export interface MetadataManagerConfig {
|
||||
fetchMetadata: FetchMetadataCallback;
|
||||
mutateMetadata: MutateMetadataCallback;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue