From 2243945d3a4a7b4f55f7f4b02dcb16486d5ebf3d Mon Sep 17 00:00:00 2001 From: Kir_Antipov Date: Wed, 4 Jan 2023 18:01:10 +0000 Subject: [PATCH] Made a class that protects a string from being exposed --- src/utils/security/secure-string.ts | 115 ++++++++++++++++++ .../unit/utils/security/secure-string.spec.ts | 55 +++++++++ 2 files changed, 170 insertions(+) create mode 100644 src/utils/security/secure-string.ts create mode 100644 tests/unit/utils/security/secure-string.spec.ts diff --git a/src/utils/security/secure-string.ts b/src/utils/security/secure-string.ts new file mode 100644 index 0000000..5100199 --- /dev/null +++ b/src/utils/security/secure-string.ts @@ -0,0 +1,115 @@ +import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto"; + +/** + * Cipher type used for encryption and decryption. + */ +const CIPHER_TYPE = "aes-256-cbc"; + +/** + * Length of the encryption key. + */ +const KEY_LENGTH = 32; + +/** + * Length of the initialization vector. + */ +const IV_LENGTH = 16; + +/** + * WeakMap to store the encrypted Buffer data of each SecureString instance. + */ +const BUFFERS = new WeakMap(); + +/** + * WeakMap to store the encryption key of each SecureString instance. + */ +const KEYS = new WeakMap(); + +/** + * WeakMap to store the initialization vector of each SecureString instance. + */ +const IVS = new WeakMap(); + +/** + * Represents a secure string, which can only be accessed when unwrapped. + */ +export class SecureString { + /** + * Constructs a new {@link SecureString} instances. + * + * @param buffer - Encrypted buffer data. + * @param key - Encryption key. + * @param iv - Initialization vector. + */ + private constructor(buffer: Buffer, key: Buffer, iv: Buffer) { + BUFFERS.set(this, buffer); + KEYS.set(this, key); + IVS.set(this, iv); + } + + /** + * Creates a new {@link SecureString} instance from a given input string, or `Buffer`. + * + * @param s - The input string, or `Buffer`. + * + * @returns A new {@link SecureString} instance. + */ + static from(s: string | Buffer | SecureString): SecureString { + if (s instanceof SecureString) { + return s; + } + + const decryptedBuffer = Buffer.from(s || ""); + const key = randomBytes(KEY_LENGTH); + const iv = randomBytes(IV_LENGTH); + + const cipher = createCipheriv(CIPHER_TYPE, key, iv); + const buffer = Buffer.concat([cipher.update(decryptedBuffer), cipher.final()]); + return new SecureString(buffer, key, iv); + } + + /** + * Unwraps the encrypted {@link SecureString} instance and returns the decrypted string. + * + * @returns Decrypted string. + */ + unwrap(): string { + const buffer = BUFFERS.get(this); + const key = KEYS.get(this); + const iv = IVS.get(this); + if (!buffer || !key || !iv) { + throw new Error("The SecureString instance was not properly initialized."); + } + + const decipher = createDecipheriv(CIPHER_TYPE, key, iv); + const decryptedBuffer = Buffer.concat([decipher.update(buffer), decipher.final()]); + return decryptedBuffer.toString(); + } + + /** + * Returns the custom string tag to identify {@link SecureString} instances. + * + * @returns "SecureString". + */ + get [Symbol.toStringTag](): string { + return "SecureString"; + } + + /** + * Return a masked string, hiding the actual content. + * + * @returns A masked string. + */ + toString(): string { + return "*****"; + } + + /** + * Return a masked string, hiding the actual content. + * + * @returns A masked string. + */ + toJSON(): string { + return this.toString(); + } +} diff --git a/tests/unit/utils/security/secure-string.spec.ts b/tests/unit/utils/security/secure-string.spec.ts new file mode 100644 index 0000000..97c952f --- /dev/null +++ b/tests/unit/utils/security/secure-string.spec.ts @@ -0,0 +1,55 @@ +import { SecureString } from "@/utils/security/secure-string"; + +describe("SecureString", () => { + describe("from", () => { + test("creates a SecureString from a string", () => { + const secureString = SecureString.from("password"); + + expect(secureString).toBeDefined(); + expect(secureString.unwrap()).toBe("password"); + }); + + test("creates a SecureString from a buffer", () => { + const secureString = SecureString.from(Buffer.from("password")); + + expect(secureString).toBeDefined(); + expect(secureString.unwrap()).toBe("password"); + }); + + test("returns a SecureString as is", () => { + const originalSecureString = SecureString.from("password"); + const secureString = SecureString.from(originalSecureString); + + expect(secureString).toBeDefined(); + expect(secureString).toStrictEqual(originalSecureString); + }); + }); + + describe("unwrap", () => { + test("returns the decrypted string when unwrapped", () => { + const secureString = SecureString.from("password"); + + expect(secureString.unwrap()).toBe("password"); + }); + + test("throws an error if trying to unwrap an improperly initialized secure string", () => { + const secureString = new (SecureString as unknown as DateConstructor)() as unknown as SecureString; + + expect(() => secureString.unwrap()).toThrowError("The SecureString instance was not properly initialized."); + }); + }); + + test("does not reveal the value when converted to a string", () => { + const secureString = SecureString.from("password"); + + expect(secureString.toString()).not.toBe("password"); + expect(String(secureString)).not.toBe("password"); + }); + + test("does not reveal the value when part of an object that is serialized to JSON", () => { + const secureString = SecureString.from("password"); + const obj = { secureString }; + + expect(JSON.stringify(obj)).not.toMatch("password"); + }); +});