From bc73fc729762e3312419941c4f5d6e73e0e91a84 Mon Sep 17 00:00:00 2001 From: Daniel Huber Date: Sun, 9 Jan 2022 22:40:03 +0100 Subject: [PATCH 01/13] Derive encryption key in dashboard using PBKDF2 --- nodecg-io-core/dashboard/crypto.ts | 30 ++++++- .../extension/persistenceManager.ts | 86 +++++++++++++++++-- 2 files changed, 106 insertions(+), 10 deletions(-) diff --git a/nodecg-io-core/dashboard/crypto.ts b/nodecg-io-core/dashboard/crypto.ts index a967c260d..b2f027854 100644 --- a/nodecg-io-core/dashboard/crypto.ts +++ b/nodecg-io-core/dashboard/crypto.ts @@ -1,8 +1,15 @@ -import { PersistentData, EncryptedData, decryptData } from "nodecg-io-core/extension/persistenceManager"; +import { + PersistentData, + EncryptedData, + decryptData, + deriveEncryptionSecret, + reEncryptData, +} from "nodecg-io-core/extension/persistenceManager"; import { EventEmitter } from "events"; import { ObjectMap, ServiceInstance, ServiceDependency, Service } from "nodecg-io-core/extension/service"; import { isLoaded } from "./authentication"; import { PasswordMessage } from "nodecg-io-core/extension/messageManager"; +import cryptoJS from "crypto-js"; const encryptedData = nodecg.Replicant("encryptedConfig"); let services: Service[] | undefined; @@ -60,7 +67,23 @@ export async function setPassword(pw: string): Promise { fetchServices(), ]); - password = pw; + if (encryptedData.value === undefined) { + encryptedData.value = {}; + } + + const salt = encryptedData.value.salt ?? cryptoJS.lib.WordArray.random(128 / 8).toString(cryptoJS.enc.Hex); + if (encryptedData.value.salt === undefined) { + const newSecret = deriveEncryptionSecret(pw, salt); + + if (encryptedData.value.cipherText !== undefined) { + const newSecretWordArray = cryptoJS.enc.Hex.parse(newSecret); + reEncryptData(encryptedData.value, pw, newSecretWordArray); + } + + encryptedData.value.salt = salt; + } + + password = deriveEncryptionSecret(pw, salt); // Load framework, returns false if not already loaded and password is wrong if ((await loadFramework()) === false) return false; @@ -99,7 +122,8 @@ export function isPasswordSet(): boolean { function updateDecryptedData(data: EncryptedData): void { let result: PersistentData | undefined = undefined; if (password !== undefined && data.cipherText) { - const res = decryptData(data.cipherText, password); + const passwordWordArray = cryptoJS.enc.Hex.parse(password); + const res = decryptData(data.cipherText, passwordWordArray, data.iv); if (!res.failed) { result = res.result; } else { diff --git a/nodecg-io-core/extension/persistenceManager.ts b/nodecg-io-core/extension/persistenceManager.ts index f04a058d6..fbefda9bc 100644 --- a/nodecg-io-core/extension/persistenceManager.ts +++ b/nodecg-io-core/extension/persistenceManager.ts @@ -1,7 +1,7 @@ import { NodeCG, ReplicantServer } from "nodecg-types/types/server"; import { InstanceManager } from "./instanceManager"; import { BundleManager } from "./bundleManager"; -import * as crypto from "crypto-js"; +import crypto from "crypto-js"; import { emptySuccess, error, Result, success } from "./utils/result"; import { ObjectMap, ServiceDependency, ServiceInstance } from "./service"; import { ServiceManager } from "./serviceManager"; @@ -28,6 +28,8 @@ export interface EncryptedData { * The encrypted format of the data that needs to be stored. */ cipherText?: string; + salt?: string; + iv?: string; } /** @@ -37,9 +39,14 @@ export interface EncryptedData { * @param cipherText the ciphertext that needs to be decrypted. * @param password the password for the encrypted data. */ -export function decryptData(cipherText: string, password: string): Result { +export function decryptData( + cipherText: string, + password: string | crypto.lib.WordArray, + iv: string | undefined, +): Result { try { - const decryptedBytes = crypto.AES.decrypt(cipherText, password); + const ivWordArray = iv ? crypto.enc.Hex.parse(iv) : undefined; + const decryptedBytes = crypto.AES.decrypt(cipherText, password, { iv: ivWordArray }); const decryptedText = decryptedBytes.toString(crypto.enc.Utf8); const data: PersistentData = JSON.parse(decryptedText); return success(data); @@ -48,6 +55,48 @@ export function decryptData(cipherText: string, password: string): Result { + if (data.cipherText === undefined) { + return error("Cannot re-encrypt empty cipher text."); + } + + const decryptedData = decryptData(data.cipherText, oldSecret, data.iv); + if (decryptedData.failed) { + return error(decryptedData.errorMessage); + } + + const [newCipherText, iv] = encryptData(decryptedData.result, newSecret); + data.cipherText = newCipherText; + data.iv = iv; + return emptySuccess(); +} + /** * Manages encrypted persistence of data that is held by the instance and bundle managers. */ @@ -116,8 +165,14 @@ export class PersistenceManager { } else { // Decrypt config this.nodecg.log.info("Decrypting and loading saved configuration."); - const data = decryptData(this.encryptedData.value.cipherText, password); + const passwordWordArray = crypto.enc.Hex.parse(password); + const data = decryptData( + this.encryptedData.value.cipherText, + passwordWordArray, + this.encryptedData.value.iv, + ); if (data.failed) { + this.nodecg.log.error("Could not decrypt configuration: password is invalid."); return data; } @@ -215,8 +270,10 @@ export class PersistenceManager { }; // Encrypt and save data to persistent replicant. - const cipherText = crypto.AES.encrypt(JSON.stringify(data), this.password); - this.encryptedData.value.cipherText = cipherText.toString(); + const passwordWordArray = crypto.enc.Hex.parse(this.password); + const [cipherText, iv] = encryptData(data, passwordWordArray); + this.encryptedData.value.cipherText = cipherText; + this.encryptedData.value.iv = iv; } /** @@ -292,7 +349,22 @@ export class PersistenceManager { if (bundles.length > 0) { try { this.nodecg.log.info("Attempting to automatically login..."); - const loadResult = await this.load(password); + + const salt = + this.encryptedData.value.salt ?? crypto.lib.WordArray.random(128 / 8).toString(crypto.enc.Hex); + if (this.encryptedData.value.salt === undefined) { + const newSecret = deriveEncryptionSecret(password, salt); + + if (this.encryptedData.value.cipherText !== undefined) { + const newSecretWordArray = crypto.enc.Hex.parse(newSecret); + reEncryptData(this.encryptedData.value, password, newSecretWordArray); + } + + this.encryptedData.value.salt = salt; + } + + const encryptionSecret = deriveEncryptionSecret(password, salt); + const loadResult = await this.load(encryptionSecret); if (!loadResult.failed) { this.nodecg.log.info("Automatic login successful."); From cef8c1ff517fdd0d79f0c95c6bd35f9655da39c6 Mon Sep 17 00:00:00 2001 From: Daniel Huber Date: Tue, 11 Jan 2022 00:09:45 +0100 Subject: [PATCH 02/13] Rename password to encryptionKey when derived key is used --- nodecg-io-core/dashboard/crypto.ts | 54 ++++++++------- nodecg-io-core/extension/messageManager.ts | 20 +++--- .../extension/persistenceManager.ts | 69 ++++++++++--------- 3 files changed, 74 insertions(+), 69 deletions(-) diff --git a/nodecg-io-core/dashboard/crypto.ts b/nodecg-io-core/dashboard/crypto.ts index b2f027854..1315655e9 100644 --- a/nodecg-io-core/dashboard/crypto.ts +++ b/nodecg-io-core/dashboard/crypto.ts @@ -2,18 +2,18 @@ import { PersistentData, EncryptedData, decryptData, - deriveEncryptionSecret, + deriveEncryptionKey, reEncryptData, } from "nodecg-io-core/extension/persistenceManager"; import { EventEmitter } from "events"; import { ObjectMap, ServiceInstance, ServiceDependency, Service } from "nodecg-io-core/extension/service"; import { isLoaded } from "./authentication"; -import { PasswordMessage } from "nodecg-io-core/extension/messageManager"; +import { AuthenticationMessage } from "nodecg-io-core/extension/messageManager"; import cryptoJS from "crypto-js"; const encryptedData = nodecg.Replicant("encryptedConfig"); let services: Service[] | undefined; -let password: string | undefined; +let encryptionKey: string | undefined; /** * Layer between the actual dashboard and `PersistentData`. @@ -47,20 +47,21 @@ class Config extends EventEmitter { } export const config = new Config(); -// Update the decrypted copy of the data once the encrypted version changes (if a password is available). +// Update the decrypted copy of the data once the encrypted version changes (if a encryption key is available). // This ensures that the decrypted data is always up-to-date. encryptedData.on("change", updateDecryptedData); /** * Sets the passed password to be used by the crypto module. - * Will try to decrypt encrypted data to tell whether the password is correct, - * if it is wrong the internal password will be set to undefined. + * Uses the password to derive a decryption secret and then tries to decrypt + * the encrypted data to tell whether the password is correct. + * If it is wrong the internal encryption key will be set to undefined. * Returns whether the password is correct. * @param pw the password which should be set. */ export async function setPassword(pw: string): Promise { await Promise.all([ - // Ensures that the `encryptedData` has been declared because it is needed by `setPassword()` + // Ensures that the `encryptedData` has been declared because it is needed to get the encrypted config. // This is especially needed when handling a re-connect as the replicant takes time to declare // and the password check is usually faster than that. NodeCG.waitForReplicants(encryptedData), @@ -73,7 +74,7 @@ export async function setPassword(pw: string): Promise { const salt = encryptedData.value.salt ?? cryptoJS.lib.WordArray.random(128 / 8).toString(cryptoJS.enc.Hex); if (encryptedData.value.salt === undefined) { - const newSecret = deriveEncryptionSecret(pw, salt); + const newSecret = deriveEncryptionKey(pw, salt); if (encryptedData.value.cipherText !== undefined) { const newSecretWordArray = cryptoJS.enc.Hex.parse(newSecret); @@ -83,16 +84,16 @@ export async function setPassword(pw: string): Promise { encryptedData.value.salt = salt; } - password = deriveEncryptionSecret(pw, salt); + encryptionKey = deriveEncryptionKey(pw, salt); - // Load framework, returns false if not already loaded and password is wrong + // Load framework, returns false if not already loaded and password/encryption key is wrong if ((await loadFramework()) === false) return false; if (encryptedData.value) { updateDecryptedData(encryptedData.value); - // Password is unset by `updateDecryptedData` if it is wrong. - // This may happen if the framework was already loaded and `loadFramework` didn't check the password. - if (password === undefined) { + // encryption key is unset by `updateDecryptedData` if it is wrong. + // This may happen if the framework was already loaded and `loadFramework` didn't check the password/encryption key. + if (encryptionKey === undefined) { return false; } } @@ -100,35 +101,38 @@ export async function setPassword(pw: string): Promise { return true; } -export async function sendAuthenticatedMessage(messageName: string, message: Partial): Promise { - if (password === undefined) throw "No password available"; +export async function sendAuthenticatedMessage( + messageName: string, + message: Partial, +): Promise { + if (encryptionKey === undefined) throw "Can't send authenticated message: crypto module not authenticated"; const msgWithAuth = Object.assign({}, message); - msgWithAuth.password = password; + msgWithAuth.encryptionKey = encryptionKey; return await nodecg.sendMessage(messageName, msgWithAuth); } /** - * Returns whether a password has been set in the crypto module aka. whether it is authenticated. + * Returns whether a password derived encryption key has been set in the crypto module aka. whether it is authenticated. */ export function isPasswordSet(): boolean { - return password !== undefined; + return encryptionKey !== undefined; } /** - * Decrypts the passed data using the global password variable and saves it into `ConfigData`. - * Unsets the password if its wrong and also forwards `undefined` to `ConfigData` if the password is unset. + * Decrypts the passed data using the global encryptionKey variable and saves it into `ConfigData`. + * Unsets the encryption key if its wrong and also forwards `undefined` to `ConfigData` if the encryption key is unset. * @param data the data that should be decrypted. */ function updateDecryptedData(data: EncryptedData): void { let result: PersistentData | undefined = undefined; - if (password !== undefined && data.cipherText) { - const passwordWordArray = cryptoJS.enc.Hex.parse(password); + if (encryptionKey !== undefined && data.cipherText) { + const passwordWordArray = cryptoJS.enc.Hex.parse(encryptionKey); const res = decryptData(data.cipherText, passwordWordArray, data.iv); if (!res.failed) { result = res.result; } else { - // Password is wrong - password = undefined; + // Secret is wrong + encryptionKey = undefined; } } @@ -159,7 +163,7 @@ async function loadFramework(): Promise { if (await isLoaded()) return true; try { - await nodecg.sendMessage("load", { password }); + await nodecg.sendMessage("load", { encryptionKey }); return true; } catch { return false; diff --git a/nodecg-io-core/extension/messageManager.ts b/nodecg-io-core/extension/messageManager.ts index 76a63bed3..754508f1d 100644 --- a/nodecg-io-core/extension/messageManager.ts +++ b/nodecg-io-core/extension/messageManager.ts @@ -5,25 +5,25 @@ import { BundleManager } from "./bundleManager"; import { PersistenceManager } from "./persistenceManager"; import { ServiceManager } from "./serviceManager"; -export interface PasswordMessage { - password: string; +export interface AuthenticationMessage { + encryptionKey: string; } -export interface UpdateInstanceConfigMessage extends PasswordMessage { +export interface UpdateInstanceConfigMessage extends AuthenticationMessage { instanceName: string; config: unknown; } -export interface CreateServiceInstanceMessage extends PasswordMessage { +export interface CreateServiceInstanceMessage extends AuthenticationMessage { serviceType: string; instanceName: string; } -export interface DeleteServiceInstanceMessage extends PasswordMessage { +export interface DeleteServiceInstanceMessage extends AuthenticationMessage { instanceName: string; } -export interface SetServiceDependencyMessage extends PasswordMessage { +export interface SetServiceDependencyMessage extends AuthenticationMessage { bundleName: string; instanceName: string | undefined; serviceType: string; @@ -82,8 +82,8 @@ export class MessageManager { return success(this.persist.isLoaded()); }); - this.listen("load", async (msg: PasswordMessage) => { - return this.persist.load(msg.password); + this.listen("load", async (msg: AuthenticationMessage) => { + return this.persist.load(msg.encryptionKey); }); this.listen("getServices", async () => { @@ -113,12 +113,12 @@ export class MessageManager { }); } - private listenWithAuth( + private listenWithAuth( messageName: string, cb: (msg: M) => Promise>, ): void { this.listen(messageName, async (msg: M) => { - if (this.persist.checkPassword(msg.password)) { + if (this.persist.checkEncryptionKey(msg.encryptionKey)) { return cb(msg); } else { return error("The password is invalid"); diff --git a/nodecg-io-core/extension/persistenceManager.ts b/nodecg-io-core/extension/persistenceManager.ts index fbefda9bc..ff1db6c92 100644 --- a/nodecg-io-core/extension/persistenceManager.ts +++ b/nodecg-io-core/extension/persistenceManager.ts @@ -33,20 +33,21 @@ export interface EncryptedData { } /** - * Decrypts the passed encrypted data using the passed password. - * If the password is wrong, an error will be returned. + * Decrypts the passed encrypted data using the passed encryption key. + * If the encryption key is wrong, an error will be returned. * * @param cipherText the ciphertext that needs to be decrypted. - * @param password the password for the encrypted data. + * @param encryptionKey the encryption key for the encrypted data. + * @param iv the initialization vector for the encrypted data. */ export function decryptData( cipherText: string, - password: string | crypto.lib.WordArray, + encryptionKey: string | crypto.lib.WordArray, iv: string | undefined, ): Result { try { const ivWordArray = iv ? crypto.enc.Hex.parse(iv) : undefined; - const decryptedBytes = crypto.AES.decrypt(cipherText, password, { iv: ivWordArray }); + const decryptedBytes = crypto.AES.decrypt(cipherText, encryptionKey, { iv: ivWordArray }); const decryptedText = decryptedBytes.toString(crypto.enc.Utf8); const data: PersistentData = JSON.parse(decryptedText); return success(data); @@ -55,14 +56,14 @@ export function decryptData( } } -export function encryptData(data: PersistentData, password: string | crypto.lib.WordArray): [string, string] { +export function encryptData(data: PersistentData, encryptionKey: crypto.lib.WordArray): [string, string] { const iv = crypto.lib.WordArray.random(16); const ivText = iv.toString(); - const encrypted = crypto.AES.encrypt(JSON.stringify(data), password, { iv }); + const encrypted = crypto.AES.encrypt(JSON.stringify(data), encryptionKey, { iv }); return [encrypted.toString(), ivText]; } -export function deriveEncryptionSecret(password: string, salt: string | undefined): string { +export function deriveEncryptionKey(password: string, salt: string | undefined): string { if (salt === undefined) { return password; } @@ -80,7 +81,7 @@ export function deriveEncryptionSecret(password: string, salt: string | undefine export function reEncryptData( data: EncryptedData, oldSecret: string | crypto.lib.WordArray, - newSecret: string | crypto.lib.WordArray, + newSecret: crypto.lib.WordArray, ): Result { if (data.cipherText === undefined) { return error("Cannot re-encrypt empty cipher text."); @@ -101,7 +102,7 @@ export function reEncryptData( * Manages encrypted persistence of data that is held by the instance and bundle managers. */ export class PersistenceManager { - private password: string | undefined; + private encryptionKey: string | undefined; // We store the encrypted data in a replicant, because writing files in a NodeCG bundle isn't very clean // and the bundle config is read-only. It is only in encrypted form, so it is OK to be accessible in the browser. private encryptedData: ReplicantServer; @@ -120,38 +121,38 @@ export class PersistenceManager { } /** - * Checks whether the passed password is correct. Only works if already loaded and a password is already set. - * @param password the password which should be checked for correctness + * Checks whether the passed encryption key is correct. Only works if already loaded and a encryption key is already set. + * @param encryptionKey the encryption key which should be checked for correctness */ - checkPassword(password: string): boolean { + checkEncryptionKey(encryptionKey: string): boolean { if (this.isLoaded()) { - return this.password === password; + return this.encryptionKey === encryptionKey; } else { return false; } } /** - * Returns if the locally stored configuration has been loaded and a password has been set. + * Returns if the locally stored configuration has been loaded and a encryption key has been set. */ isLoaded(): boolean { - return this.password !== undefined; + return this.encryptionKey !== undefined; } /** * Returns whether this is the first startup aka. whether any encrypted data has been saved. - * If this returns true {{@link load}} will accept any password and use it to encrypt the configuration. + * If this returns true {@link load} will accept any encryption key and use it to encrypt the configuration. */ isFirstStartup(): boolean { return this.encryptedData.value.cipherText === undefined; } /** - * Decrypts and loads the locally stored configuration using the passed password. - * @param password the password of the encrypted config. - * @return success if the password was correct and loading has been successful and an error if the password is wrong. + * Decrypts and loads the locally stored configuration using the passed encryption key. + * @param encryptionKey the encryption key of the encrypted config. + * @return success if the encryption key was correct and loading has been successful and an error if the encryption key is wrong. */ - async load(password: string): Promise> { + async load(encryptionKey: string): Promise> { if (this.isLoaded()) { return error("Config has already been decrypted and loaded."); } @@ -160,19 +161,19 @@ export class PersistenceManager { // No encrypted data has been saved, probably because this is the first startup. // Therefore nothing needs to be decrypted, and we write an empty config to disk. this.nodecg.log.info("No saved configuration found, creating a empty one."); - this.password = password; + this.encryptionKey = encryptionKey; this.save(); } else { // Decrypt config this.nodecg.log.info("Decrypting and loading saved configuration."); - const passwordWordArray = crypto.enc.Hex.parse(password); + const encryptionKeyArr = crypto.enc.Hex.parse(encryptionKey); const data = decryptData( this.encryptedData.value.cipherText, - passwordWordArray, + encryptionKeyArr, this.encryptedData.value.iv, ); if (data.failed) { - this.nodecg.log.error("Could not decrypt configuration: password is invalid."); + this.nodecg.log.error("Could not decrypt configuration: encryption key is invalid."); return data; } @@ -183,8 +184,8 @@ export class PersistenceManager { this.saveAfterServiceInstancesLoaded(promises); } - // Save password, used in save() function - this.password = password; + // Save encryption key, used in save() function + this.encryptionKey = encryptionKey; // Register handlers to save when something changes this.instances.on("change", () => this.save()); @@ -258,8 +259,8 @@ export class PersistenceManager { * Encrypts and saves current state to the persistent replicant. */ save(): void { - // Check if we have a password to encrypt the data with. - if (this.password === undefined) { + // Check if we have a encryption key to encrypt the data with. + if (this.encryptionKey === undefined) { return; } @@ -270,8 +271,8 @@ export class PersistenceManager { }; // Encrypt and save data to persistent replicant. - const passwordWordArray = crypto.enc.Hex.parse(this.password); - const [cipherText, iv] = encryptData(data, passwordWordArray); + const encryptionKeyArr = crypto.enc.Hex.parse(this.encryptionKey); + const [cipherText, iv] = encryptData(data, encryptionKeyArr); this.encryptedData.value.cipherText = cipherText; this.encryptedData.value.iv = iv; } @@ -353,7 +354,7 @@ export class PersistenceManager { const salt = this.encryptedData.value.salt ?? crypto.lib.WordArray.random(128 / 8).toString(crypto.enc.Hex); if (this.encryptedData.value.salt === undefined) { - const newSecret = deriveEncryptionSecret(password, salt); + const newSecret = deriveEncryptionKey(password, salt); if (this.encryptedData.value.cipherText !== undefined) { const newSecretWordArray = crypto.enc.Hex.parse(newSecret); @@ -363,8 +364,8 @@ export class PersistenceManager { this.encryptedData.value.salt = salt; } - const encryptionSecret = deriveEncryptionSecret(password, salt); - const loadResult = await this.load(encryptionSecret); + const encryptionKey = deriveEncryptionKey(password, salt); + const loadResult = await this.load(encryptionKey); if (!loadResult.failed) { this.nodecg.log.info("Automatic login successful."); From 02f021468b565eb7357580e1c0bd50ec3b06925c Mon Sep 17 00:00:00 2001 From: Daniel Huber Date: Tue, 11 Jan 2022 01:15:48 +0100 Subject: [PATCH 03/13] Document newly added functions and properties --- nodecg-io-core/dashboard/crypto.ts | 15 +++-- .../extension/persistenceManager.ts | 57 +++++++++++++++---- 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/nodecg-io-core/dashboard/crypto.ts b/nodecg-io-core/dashboard/crypto.ts index 1315655e9..d05978871 100644 --- a/nodecg-io-core/dashboard/crypto.ts +++ b/nodecg-io-core/dashboard/crypto.ts @@ -72,13 +72,20 @@ export async function setPassword(pw: string): Promise { encryptedData.value = {}; } - const salt = encryptedData.value.salt ?? cryptoJS.lib.WordArray.random(128 / 8).toString(cryptoJS.enc.Hex); + const salt = encryptedData.value.salt ?? cryptoJS.lib.WordArray.random(128 / 8).toString(); + // Check if no salt is present, which is the case for the nodecg-io <=0.2 configs + // where crypto-js derived the encryption key and managed the salt. if (encryptedData.value.salt === undefined) { - const newSecret = deriveEncryptionKey(pw, salt); + // Salt is unset when nodecg-io is first started. if (encryptedData.value.cipherText !== undefined) { - const newSecretWordArray = cryptoJS.enc.Hex.parse(newSecret); - reEncryptData(encryptedData.value, pw, newSecretWordArray); + // Salt is unset but we have some encrypted data. + // This means that this is a old config, that we need to migrate to the new format. + + // Re-encrypt the configuration using our own derived key instead of the password. + const newEncryptionKey = deriveEncryptionKey(pw, salt); + const newEncryptionKeyArr = cryptoJS.enc.Hex.parse(newEncryptionKey); + reEncryptData(encryptedData.value, pw, newEncryptionKeyArr); } encryptedData.value.salt = salt; diff --git a/nodecg-io-core/extension/persistenceManager.ts b/nodecg-io-core/extension/persistenceManager.ts index ff1db6c92..8755d54a9 100644 --- a/nodecg-io-core/extension/persistenceManager.ts +++ b/nodecg-io-core/extension/persistenceManager.ts @@ -28,7 +28,17 @@ export interface EncryptedData { * The encrypted format of the data that needs to be stored. */ cipherText?: string; + + /** + * The salt that is used when deriving the encryption key from the password. + * Only set for new format with nodecg-io >=0.3. + */ salt?: string; + + /** + * The initialization vector used for encryption. + * Only set for new format with nodecg-io >=0.3. + */ iv?: string; } @@ -36,6 +46,9 @@ export interface EncryptedData { * Decrypts the passed encrypted data using the passed encryption key. * If the encryption key is wrong, an error will be returned. * + * This function supports the <=0.2 format with the plain password as an + * encryption key and no iv (read from ciphertext) and the >=0.3 format with the iv and derived key. + * * @param cipherText the ciphertext that needs to be decrypted. * @param encryptionKey the encryption key for the encrypted data. * @param iv the initialization vector for the encrypted data. @@ -56,6 +69,13 @@ export function decryptData( } } +/** + * Encrypts the passed data objedt using the passed encryption key. + * + * @param data the data that needs to be encrypted. + * @param encryptionKey the encryption key that should be used to encrypt the data. + * @returns a tuple containing the encrypted data and the initialization vector as a hex string. + */ export function encryptData(data: PersistentData, encryptionKey: crypto.lib.WordArray): [string, string] { const iv = crypto.lib.WordArray.random(16); const ivText = iv.toString(); @@ -63,11 +83,14 @@ export function encryptData(data: PersistentData, encryptionKey: crypto.lib.Word return [encrypted.toString(), ivText]; } -export function deriveEncryptionKey(password: string, salt: string | undefined): string { - if (salt === undefined) { - return password; - } - +/** + * Derives a key suitable for encrypting the config from the given password. + * + * @param password the password from which the encryption key will be derived. + * @param salt the salt that is used for key derivation. + * @returns a hex encoded string of the derived key. + */ +export function deriveEncryptionKey(password: string, salt: string): string { const saltWordArray = crypto.enc.Hex.parse(salt); return crypto @@ -78,6 +101,14 @@ export function deriveEncryptionKey(password: string, salt: string | undefined): .toString(crypto.enc.Hex); } +/** + * Re-encrypts the passed data to change the password/encryption key. + * Currently only used to migrate from <=0.2 to >=0.3 config formats but + * could be used to implement a change password feature in the future. + * @param data the data that should be re-encrypted. + * @param oldSecret the previous encryption key or password. + * @param newSecret the new encryption key. + */ export function reEncryptData( data: EncryptedData, oldSecret: string | crypto.lib.WordArray, @@ -351,14 +382,20 @@ export class PersistenceManager { try { this.nodecg.log.info("Attempting to automatically login..."); - const salt = - this.encryptedData.value.salt ?? crypto.lib.WordArray.random(128 / 8).toString(crypto.enc.Hex); + const salt = this.encryptedData.value.salt ?? crypto.lib.WordArray.random(128 / 8).toString(); + // Check if no salt is present, which is the case for the nodecg-io <=0.2 configs + // where crypto-js derived the encryption key and managed the salt. if (this.encryptedData.value.salt === undefined) { - const newSecret = deriveEncryptionKey(password, salt); + // Salt is unset when nodecg-io is first started. if (this.encryptedData.value.cipherText !== undefined) { - const newSecretWordArray = crypto.enc.Hex.parse(newSecret); - reEncryptData(this.encryptedData.value, password, newSecretWordArray); + // Salt is unset but we have some encrypted data. + // This means that this is a old config, that we need to migrate to the new format. + + // Re-encrypt the configuration using our own derived key instead of the password. + const newEncryptionKey = deriveEncryptionKey(password, salt); + const newEncryptionKeyArr = crypto.enc.Hex.parse(newEncryptionKey); + reEncryptData(this.encryptedData.value, password, newEncryptionKeyArr); } this.encryptedData.value.salt = salt; From 2f4fff6814a237dbea9172652c1efa756a04334d Mon Sep 17 00:00:00 2001 From: Daniel Huber Date: Tue, 11 Jan 2022 18:14:52 +0100 Subject: [PATCH 04/13] Update and fix PersistenceManager tests --- .../extension/__tests__/persistenceManager.ts | 101 +++++++++++------- 1 file changed, 63 insertions(+), 38 deletions(-) diff --git a/nodecg-io-core/extension/__tests__/persistenceManager.ts b/nodecg-io-core/extension/__tests__/persistenceManager.ts index b87754e71..094920c71 100644 --- a/nodecg-io-core/extension/__tests__/persistenceManager.ts +++ b/nodecg-io-core/extension/__tests__/persistenceManager.ts @@ -1,7 +1,13 @@ import * as crypto from "crypto-js"; import { BundleManager } from "../bundleManager"; import { InstanceManager } from "../instanceManager"; -import { decryptData, EncryptedData, PersistenceManager, PersistentData } from "../persistenceManager"; +import { + decryptData, + deriveEncryptionKey, + EncryptedData, + PersistenceManager, + PersistentData, +} from "../persistenceManager"; import { ServiceManager } from "../serviceManager"; import { ServiceProvider } from "../serviceProvider"; import { emptySuccess, error } from "../utils/result"; @@ -9,7 +15,10 @@ import { MockNodeCG, testBundle, testInstance, testService, testServiceInstance describe("PersistenceManager", () => { const validPassword = "myPassword"; - const invalidPassword = "someOtherPassword"; + const invalidPassword = "myInvalidPassword"; + const salt = crypto.lib.WordArray.random(128 / 8).toString(); + const validEncryptionKey = deriveEncryptionKey(validPassword, salt).toString(); + const invalidEncryptionKey = deriveEncryptionKey(invalidPassword, salt).toString(); const nodecg = new MockNodeCG(); const serviceManager = new ServiceManager(nodecg); @@ -39,7 +48,7 @@ describe("PersistenceManager", () => { * Creates a basic config and encrypts it. Used to check whether load decrypts and more importantly * restores the same configuration again. */ - function generateEncryptedConfig(data?: PersistentData) { + function generateEncryptedConfig(data?: PersistentData): EncryptedData { const d: PersistentData = data ? data : { @@ -56,22 +65,28 @@ describe("PersistenceManager", () => { [testInstance]: testServiceInstance, }, }; - return crypto.AES.encrypt(JSON.stringify(d), validPassword).toString(); + + const iv = crypto.lib.WordArray.random(16); + const encryptionKeyArray = crypto.enc.Hex.parse(validEncryptionKey); + return { + cipherText: crypto.AES.encrypt(JSON.stringify(d), encryptionKeyArray, { iv }).toString(), + iv: iv.toString(), + }; } - describe("checkPassword", () => { + describe("checkEncryptionKey", () => { test("should return false if not loaded", () => { - expect(persistenceManager.checkPassword(validPassword)).toBe(false); + expect(persistenceManager.checkEncryptionKey(validEncryptionKey)).toBe(false); }); test("should return false if loaded but password is wrong", async () => { - await persistenceManager.load(validPassword); - expect(persistenceManager.checkPassword(invalidPassword)).toBe(false); + await persistenceManager.load(validEncryptionKey); + expect(persistenceManager.checkEncryptionKey(invalidEncryptionKey)).toBe(false); }); test("should return true if loaded and password is correct", async () => { - await persistenceManager.load(validPassword); - expect(persistenceManager.checkPassword(validPassword)).toBe(true); + await persistenceManager.load(validEncryptionKey); + expect(persistenceManager.checkEncryptionKey(validEncryptionKey)).toBe(true); }); }); @@ -81,15 +96,15 @@ describe("PersistenceManager", () => { }); test("should return false if load was called but failed", async () => { - encryptedDataReplicant.value.cipherText = generateEncryptedConfig(); - const res = await persistenceManager.load(invalidPassword); // Will fail because the password is invalid + encryptedDataReplicant.value = generateEncryptedConfig(); + const res = await persistenceManager.load(invalidEncryptionKey); // Will fail because the password is invalid expect(res.failed).toBe(true); expect(persistenceManager.isLoaded()).toBe(false); }); test("should return true if load was called and succeeded", async () => { - encryptedDataReplicant.value.cipherText = generateEncryptedConfig(); - const res = await persistenceManager.load(validPassword); // password is correct, should work + encryptedDataReplicant.value = generateEncryptedConfig(); + const res = await persistenceManager.load(validEncryptionKey); // password is correct, should work expect(res.failed).toBe(false); expect(persistenceManager.isLoaded()).toBe(true); }); @@ -102,20 +117,20 @@ describe("PersistenceManager", () => { }); test("should return false if an encrypted config exists", () => { - encryptedDataReplicant.value.cipherText = generateEncryptedConfig(); // config = not a first startup + encryptedDataReplicant.value = generateEncryptedConfig(); // config = not a first startup expect(persistenceManager.isFirstStartup()).toBe(false); }); }); describe("load", () => { - beforeEach(() => (encryptedDataReplicant.value.cipherText = generateEncryptedConfig())); + beforeEach(() => (encryptedDataReplicant.value = generateEncryptedConfig())); // General test("should error if called after configuration already has been loaded", async () => { - const res1 = await persistenceManager.load(validPassword); + const res1 = await persistenceManager.load(validEncryptionKey); expect(res1.failed).toBe(false); - const res2 = await persistenceManager.load(validPassword); + const res2 = await persistenceManager.load(validEncryptionKey); expect(res2.failed).toBe(true); if (res2.failed) { expect(res2.errorMessage).toContain("already been decrypted and loaded"); @@ -123,13 +138,13 @@ describe("PersistenceManager", () => { }); test("should save current state if no encrypted config was found", async () => { - const res = await persistenceManager.load(validPassword); + const res = await persistenceManager.load(validEncryptionKey); expect(res.failed).toBe(false); expect(encryptedDataReplicant.value.cipherText).toBeDefined(); }); test("should error if password is wrong", async () => { - const res = await persistenceManager.load(invalidPassword); + const res = await persistenceManager.load(invalidEncryptionKey); expect(res.failed).toBe(true); if (res.failed) { expect(res.errorMessage).toContain("Password isn't correct"); @@ -137,14 +152,14 @@ describe("PersistenceManager", () => { }); test("should succeed if password is correct", async () => { - const res = await persistenceManager.load(validPassword); + const res = await persistenceManager.load(validEncryptionKey); expect(res.failed).toBe(false); }); // Service instances test("should load service instances including configuration", async () => { - await persistenceManager.load(validPassword); + await persistenceManager.load(validEncryptionKey); const inst = instanceManager.getServiceInstance(testInstance); expect(inst).toBeDefined(); if (!inst) return; @@ -153,13 +168,13 @@ describe("PersistenceManager", () => { }); test("should log failures when creating service instances", async () => { - encryptedDataReplicant.value.cipherText = generateEncryptedConfig({ + encryptedDataReplicant.value = generateEncryptedConfig({ instances: { "": testServiceInstance, // This is invalid because the instance name is empty }, bundleDependencies: {}, }); - await persistenceManager.load(validPassword); + await persistenceManager.load(validEncryptionKey); expect(nodecg.log.warn).toHaveBeenCalledTimes(1); expect(nodecg.log.warn.mock.calls[0][0]).toContain("Couldn't load instance"); expect(nodecg.log.warn.mock.calls[0][0]).toContain("name must not be empty"); @@ -167,7 +182,7 @@ describe("PersistenceManager", () => { test("should not set instance config when no config is required", async () => { testService.requiresNoConfig = true; - await persistenceManager.load(validPassword); + await persistenceManager.load(validEncryptionKey); const inst = instanceManager.getServiceInstance(testInstance); if (!inst) throw new Error("instance was not re-created"); @@ -182,7 +197,7 @@ describe("PersistenceManager", () => { test("should log failures when setting service instance configs", async () => { const errorMsg = "client error message"; testService.createClient.mockImplementationOnce(() => error(errorMsg)); - await persistenceManager.load(validPassword); + await persistenceManager.load(validEncryptionKey); // Wait for all previous promises created by loading to settle. await new Promise((res) => setImmediate(res)); @@ -194,7 +209,7 @@ describe("PersistenceManager", () => { // Service dependency assignments test("should load service dependency assignments", async () => { - await persistenceManager.load(validPassword); + await persistenceManager.load(validEncryptionKey); const deps = bundleManager.getBundleDependencies()[testBundle]; expect(deps).toBeDefined(); if (!deps) return; @@ -204,7 +219,7 @@ describe("PersistenceManager", () => { }); test("should unset service dependencies when the underlying instance was deleted", async () => { - encryptedDataReplicant.value.cipherText = generateEncryptedConfig({ + encryptedDataReplicant.value = generateEncryptedConfig({ instances: {}, bundleDependencies: { [testBundle]: [ @@ -216,7 +231,7 @@ describe("PersistenceManager", () => { ], }, }); - await persistenceManager.load(validPassword); + await persistenceManager.load(validEncryptionKey); const deps = bundleManager.getBundleDependencies()[testBundle]; expect(deps?.[0]).toBeDefined(); @@ -224,7 +239,7 @@ describe("PersistenceManager", () => { }); test("should support unassigned service dependencies", async () => { - encryptedDataReplicant.value.cipherText = generateEncryptedConfig({ + encryptedDataReplicant.value = generateEncryptedConfig({ instances: {}, bundleDependencies: { [testBundle]: [ @@ -236,7 +251,7 @@ describe("PersistenceManager", () => { ], }, }); - await persistenceManager.load(validPassword); + await persistenceManager.load(validEncryptionKey); const deps = bundleManager.getBundleDependencies()[testBundle]; expect(deps?.[0]).toBeDefined(); @@ -251,7 +266,7 @@ describe("PersistenceManager", () => { }); test("should encrypt and save configuration if framework is loaded", async () => { - const res = await persistenceManager.load(validPassword); + const res = await persistenceManager.load(validEncryptionKey); expect(res.failed).toBe(false); instanceManager.createServiceInstance(testService.serviceType, testInstance); @@ -267,8 +282,12 @@ describe("PersistenceManager", () => { if (!encryptedDataReplicant.value.cipherText) return; // Decrypt and check that the information that was saved is correct - const data = decryptData(encryptedDataReplicant.value.cipherText, validPassword); - if (data.failed) throw new Error("could not decrypt newly encrypted data"); + const data = decryptData( + encryptedDataReplicant.value.cipherText, + crypto.enc.Hex.parse(validEncryptionKey), + encryptedDataReplicant.value.iv, + ); + if (data.failed) throw new Error("could not decrypt newly encrypted data: " + data.errorMessage); expect(data.result.instances[testInstance]?.serviceType).toBe(testService.serviceType); expect(data.result.instances[testInstance]?.config).toBe(testService.defaultConfig); @@ -286,13 +305,19 @@ describe("PersistenceManager", () => { nodecg.log.error.mockReset(); persistenceManager = new PersistenceManager(nodecg, serviceManager, instanceManager, bundleManager); - persistenceManager.load = jest.fn().mockImplementation(async (password: string) => { - if (password === validPassword) return emptySuccess(); - else return error("password invalid"); + persistenceManager.load = jest.fn().mockImplementation(async (encryptionKey: string) => { + if (encryptionKey === validEncryptionKey) return emptySuccess(); + else return error("encryption key invalid"); }); nodecgBundleReplicant.value = bundleRepValue ?? [nodecg.bundleName]; } + beforeEach(() => { + encryptedDataReplicant.value = { + salt, + }; + }); + afterEach(() => { nodecg.bundleConfig = {}; nodecgBundleReplicant.removeAllListeners(); @@ -377,7 +402,7 @@ describe("PersistenceManager", () => { }); test("should automatically save if BundleManager or InstanceManager emit a change event", async () => { - await persistenceManager.load(validPassword); // Set password so that we can save stuff + await persistenceManager.load(validEncryptionKey); // Set password so that we can save stuff encryptedDataReplicant.value.cipherText = undefined; bundleManager.emit("change"); From 790d2b1319e2827262364a87b298de88ba8a2fe9 Mon Sep 17 00:00:00 2001 From: Daniel Huber Date: Tue, 11 Jan 2022 18:19:06 +0100 Subject: [PATCH 05/13] Handle error when migrating configuration to >=0.3 format --- nodecg-io-core/dashboard/crypto.ts | 7 +++++-- nodecg-io-core/extension/persistenceManager.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/nodecg-io-core/dashboard/crypto.ts b/nodecg-io-core/dashboard/crypto.ts index d05978871..2d8c70f69 100644 --- a/nodecg-io-core/dashboard/crypto.ts +++ b/nodecg-io-core/dashboard/crypto.ts @@ -85,7 +85,10 @@ export async function setPassword(pw: string): Promise { // Re-encrypt the configuration using our own derived key instead of the password. const newEncryptionKey = deriveEncryptionKey(pw, salt); const newEncryptionKeyArr = cryptoJS.enc.Hex.parse(newEncryptionKey); - reEncryptData(encryptedData.value, pw, newEncryptionKeyArr); + const res = reEncryptData(encryptedData.value, pw, newEncryptionKeyArr); + if (res.failed) { + throw new Error(`Failed to migrate config: ${res.errorMessage}`); + } } encryptedData.value.salt = salt; @@ -127,7 +130,7 @@ export function isPasswordSet(): boolean { /** * Decrypts the passed data using the global encryptionKey variable and saves it into `ConfigData`. - * Unsets the encryption key if its wrong and also forwards `undefined` to `ConfigData` if the encryption key is unset. + * Clears the encryption key if its wrong and also forwards `undefined` to `ConfigData` if the encryption key is unset. * @param data the data that should be decrypted. */ function updateDecryptedData(data: EncryptedData): void { diff --git a/nodecg-io-core/extension/persistenceManager.ts b/nodecg-io-core/extension/persistenceManager.ts index 8755d54a9..8e5525523 100644 --- a/nodecg-io-core/extension/persistenceManager.ts +++ b/nodecg-io-core/extension/persistenceManager.ts @@ -70,7 +70,7 @@ export function decryptData( } /** - * Encrypts the passed data objedt using the passed encryption key. + * Encrypts the passed data object using the passed encryption key. * * @param data the data that needs to be encrypted. * @param encryptionKey the encryption key that should be used to encrypt the data. @@ -395,7 +395,10 @@ export class PersistenceManager { // Re-encrypt the configuration using our own derived key instead of the password. const newEncryptionKey = deriveEncryptionKey(password, salt); const newEncryptionKeyArr = crypto.enc.Hex.parse(newEncryptionKey); - reEncryptData(this.encryptedData.value, password, newEncryptionKeyArr); + const res = reEncryptData(this.encryptedData.value, password, newEncryptionKeyArr); + if (res.failed) { + throw new Error(`Failed to migrate config: ${res.errorMessage}`); + } } this.encryptedData.value.salt = salt; @@ -407,7 +410,7 @@ export class PersistenceManager { if (!loadResult.failed) { this.nodecg.log.info("Automatic login successful."); } else { - throw loadResult.errorMessage; + throw new Error(loadResult.errorMessage); } } catch (err) { const logMesssage = `Failed to automatically login: ${err}`; From 6d549da85eb5b031fcdfde50576df1d356c05003 Mon Sep 17 00:00:00 2001 From: Daniel Huber Date: Tue, 11 Jan 2022 18:24:36 +0100 Subject: [PATCH 06/13] Fix last test names and comments regarding password/encryption key --- .../extension/__tests__/persistenceManager.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/nodecg-io-core/extension/__tests__/persistenceManager.ts b/nodecg-io-core/extension/__tests__/persistenceManager.ts index 094920c71..f8d839093 100644 --- a/nodecg-io-core/extension/__tests__/persistenceManager.ts +++ b/nodecg-io-core/extension/__tests__/persistenceManager.ts @@ -79,12 +79,12 @@ describe("PersistenceManager", () => { expect(persistenceManager.checkEncryptionKey(validEncryptionKey)).toBe(false); }); - test("should return false if loaded but password is wrong", async () => { + test("should return false if loaded but encryption key is wrong", async () => { await persistenceManager.load(validEncryptionKey); expect(persistenceManager.checkEncryptionKey(invalidEncryptionKey)).toBe(false); }); - test("should return true if loaded and password is correct", async () => { + test("should return true if loaded and encryption key is correct", async () => { await persistenceManager.load(validEncryptionKey); expect(persistenceManager.checkEncryptionKey(validEncryptionKey)).toBe(true); }); @@ -97,14 +97,14 @@ describe("PersistenceManager", () => { test("should return false if load was called but failed", async () => { encryptedDataReplicant.value = generateEncryptedConfig(); - const res = await persistenceManager.load(invalidEncryptionKey); // Will fail because the password is invalid + const res = await persistenceManager.load(invalidEncryptionKey); // Will fail because the encryption key is invalid expect(res.failed).toBe(true); expect(persistenceManager.isLoaded()).toBe(false); }); test("should return true if load was called and succeeded", async () => { encryptedDataReplicant.value = generateEncryptedConfig(); - const res = await persistenceManager.load(validEncryptionKey); // password is correct, should work + const res = await persistenceManager.load(validEncryptionKey); // encryption key is correct, should work expect(res.failed).toBe(false); expect(persistenceManager.isLoaded()).toBe(true); }); @@ -143,7 +143,7 @@ describe("PersistenceManager", () => { expect(encryptedDataReplicant.value.cipherText).toBeDefined(); }); - test("should error if password is wrong", async () => { + test("should error if encryption key is wrong", async () => { const res = await persistenceManager.load(invalidEncryptionKey); expect(res.failed).toBe(true); if (res.failed) { @@ -151,7 +151,7 @@ describe("PersistenceManager", () => { } }); - test("should succeed if password is correct", async () => { + test("should succeed if encryption key is correct", async () => { const res = await persistenceManager.load(validEncryptionKey); expect(res.failed).toBe(false); }); @@ -402,7 +402,7 @@ describe("PersistenceManager", () => { }); test("should automatically save if BundleManager or InstanceManager emit a change event", async () => { - await persistenceManager.load(validEncryptionKey); // Set password so that we can save stuff + await persistenceManager.load(validEncryptionKey); // Set encryption key so that we can save stuff encryptedDataReplicant.value.cipherText = undefined; bundleManager.emit("change"); From b504cc5a9439c6f6e052976664dc64738f842562 Mon Sep 17 00:00:00 2001 From: Daniel Huber Date: Sat, 23 Jul 2022 20:09:49 +0200 Subject: [PATCH 07/13] Add explaination of PBKDF2 options and use SHA256 instead of SHA1 --- nodecg-io-core/extension/persistenceManager.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/nodecg-io-core/extension/persistenceManager.ts b/nodecg-io-core/extension/persistenceManager.ts index 8e5525523..4b6c36262 100644 --- a/nodecg-io-core/extension/persistenceManager.ts +++ b/nodecg-io-core/extension/persistenceManager.ts @@ -95,8 +95,26 @@ export function deriveEncryptionKey(password: string, salt: string): string { return crypto .PBKDF2(password, saltWordArray, { + // Generate a 256 bit long key for AES-256. keySize: 256 / 32, + // Iterations should ideally be as high as possible. + // OWASP recommends 310.000 iterations for PBKDF2 with SHA-256 [https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2]. + // The problem that we have here is that this is run inside the browser + // and we must use the JavaScript implementation which is slow. + // There is the SubtleCrypto API in browsers that is implemented in native code inside the browser and can use cryptographic CPU extensions. + // However SubtleCrypto is only available in secure contexts (https) so we cannot use it + // because nodecg-io should be usable on e.g. raspberry pi on a local trusted network. + // So were left with only 5000 iterations which were determined + // by checking how many iterations are possible on a AMD Ryzen 5 1600 in a single second + // which should be acceptable time for logging in. Slower CPUs will take longer, + // so I didn't want to increase this any further. + + // For comparison: the crypto.js internal key generation function that was used in nodecg.io <0.3 configs + // used PBKDF1 based on a single MD5 iteration (yes, that is really the default in crypto.js...). + // So this is still a big improvement in comparison to the old config format. iterations: 5000, + // Use SHA-256 as the hashing algorithm. crypto.js defaults to SHA-1 which is less secure. + hasher: crypto.algo.SHA256, }) .toString(crypto.enc.Hex); } From f3ee48772998a8ea54d89b38738ae34dfa4b6352 Mon Sep 17 00:00:00 2001 From: Daniel Huber Date: Sun, 24 Jul 2022 22:34:31 +0200 Subject: [PATCH 08/13] Remove salt generation and config migration redundancy and add config version explainations --- nodecg-io-core/dashboard/crypto.ts | 26 +----- .../extension/persistenceManager.ts | 80 +++++++++++++------ 2 files changed, 60 insertions(+), 46 deletions(-) diff --git a/nodecg-io-core/dashboard/crypto.ts b/nodecg-io-core/dashboard/crypto.ts index 2d8c70f69..67f15b657 100644 --- a/nodecg-io-core/dashboard/crypto.ts +++ b/nodecg-io-core/dashboard/crypto.ts @@ -4,6 +4,7 @@ import { decryptData, deriveEncryptionKey, reEncryptData, + ensureEncryptionSaltIsSet, } from "nodecg-io-core/extension/persistenceManager"; import { EventEmitter } from "events"; import { ObjectMap, ServiceInstance, ServiceDependency, Service } from "nodecg-io-core/extension/service"; @@ -72,29 +73,8 @@ export async function setPassword(pw: string): Promise { encryptedData.value = {}; } - const salt = encryptedData.value.salt ?? cryptoJS.lib.WordArray.random(128 / 8).toString(); - // Check if no salt is present, which is the case for the nodecg-io <=0.2 configs - // where crypto-js derived the encryption key and managed the salt. - if (encryptedData.value.salt === undefined) { - // Salt is unset when nodecg-io is first started. - - if (encryptedData.value.cipherText !== undefined) { - // Salt is unset but we have some encrypted data. - // This means that this is a old config, that we need to migrate to the new format. - - // Re-encrypt the configuration using our own derived key instead of the password. - const newEncryptionKey = deriveEncryptionKey(pw, salt); - const newEncryptionKeyArr = cryptoJS.enc.Hex.parse(newEncryptionKey); - const res = reEncryptData(encryptedData.value, pw, newEncryptionKeyArr); - if (res.failed) { - throw new Error(`Failed to migrate config: ${res.errorMessage}`); - } - } - - encryptedData.value.salt = salt; - } - - encryptionKey = deriveEncryptionKey(pw, salt); + ensureEncryptionSaltIsSet(encryptedData.value, pw); + encryptionKey = deriveEncryptionKey(pw, encryptedData.value.salt ?? ""); // Load framework, returns false if not already loaded and password/encryption key is wrong if ((await loadFramework()) === false) return false; diff --git a/nodecg-io-core/extension/persistenceManager.ts b/nodecg-io-core/extension/persistenceManager.ts index 4b6c36262..1208d7710 100644 --- a/nodecg-io-core/extension/persistenceManager.ts +++ b/nodecg-io-core/extension/persistenceManager.ts @@ -22,6 +22,20 @@ export interface PersistentData { /** * Models all the data that needs to be persistent in an encrypted manner. + * + * For nodecg-io <= 0.2 configurations only the ciphertext value may be set + * containing the encrypted data, iv and salt in the crypto.js format. + * Salt and iv are managed by crypto.js and all AES defaults with a password are used (PBKDF1 using 1 MD5 iteration). + * All this happens in the nodecg-io-core extension and the password is sent using NodeCG Messages. + * + * For nodecg-io >= 0.3 this was changed. PBKDF2 using SHA256 is directly run inside the browser when logging in. + * Only the derived AES encryption key is sent to the extension using NodeCG messages. + * That way analyzed network traffic and malicious bundles that listen for the same NodeCG message only allow getting + * the encryption key and not the plain text password that may be used somewhere else. + * + * Still with this security upgrade you should only use trusted bundles with your NodeCG installation + * and use https if your using the dashboard over a untrusted network. + * */ export interface EncryptedData { /** @@ -147,6 +161,47 @@ export function reEncryptData( return emptySuccess(); } +/** + * Ensures that the passed encrypted data has the salt attribute set. + * The salt attribute is not set when either this is the first start of nodecg-io + * or if this is a old config from nodecg-io <= 0.2. + * + * If this is a new configuration a new salt will be generated and set inside the EncryptedData object. + * If this is a old configuration from nodecg-io <= 0.2 it will be migrated to the new format as well. + * + * @param data the encrypted data where the salt should be ensured to be available + * @param password the password of the encrypted data. Used if this config needs to be migrated + */ +export function ensureEncryptionSaltIsSet(data: EncryptedData, password: string): void { + if (data.salt !== undefined) { + // We already have a salt, so we have the new (nodecg-io >=0.3) format too. + // We don't need to do anything then. + return; + } + + // No salt is present, which is the case for the nodecg-io <=0.2 configs + // where crypto-js derived the encryption key and managed the salt + // or when nodecg-io is first started. + + // Generate a random salt. + const salt = crypto.lib.WordArray.random(128 / 8).toString(); + + if (data.cipherText !== undefined) { + // Salt is unset but we have some encrypted data. + // This means that this is a old config (nodecg-io <=0.2), that we need to migrate to the new format. + + // Re-encrypt the configuration using our own derived key instead of the password. + const newEncryptionKey = deriveEncryptionKey(password, salt); + const newEncryptionKeyArr = crypto.enc.Hex.parse(newEncryptionKey); + const res = reEncryptData(data, password, newEncryptionKeyArr); + if (res.failed) { + throw new Error(`Failed to migrate config: ${res.errorMessage}`); + } + } + + data.salt = salt; +} + /** * Manages encrypted persistence of data that is held by the instance and bundle managers. */ @@ -400,29 +455,8 @@ export class PersistenceManager { try { this.nodecg.log.info("Attempting to automatically login..."); - const salt = this.encryptedData.value.salt ?? crypto.lib.WordArray.random(128 / 8).toString(); - // Check if no salt is present, which is the case for the nodecg-io <=0.2 configs - // where crypto-js derived the encryption key and managed the salt. - if (this.encryptedData.value.salt === undefined) { - // Salt is unset when nodecg-io is first started. - - if (this.encryptedData.value.cipherText !== undefined) { - // Salt is unset but we have some encrypted data. - // This means that this is a old config, that we need to migrate to the new format. - - // Re-encrypt the configuration using our own derived key instead of the password. - const newEncryptionKey = deriveEncryptionKey(password, salt); - const newEncryptionKeyArr = crypto.enc.Hex.parse(newEncryptionKey); - const res = reEncryptData(this.encryptedData.value, password, newEncryptionKeyArr); - if (res.failed) { - throw new Error(`Failed to migrate config: ${res.errorMessage}`); - } - } - - this.encryptedData.value.salt = salt; - } - - const encryptionKey = deriveEncryptionKey(password, salt); + ensureEncryptionSaltIsSet(this.encryptedData.value, password); + const encryptionKey = deriveEncryptionKey(password, this.encryptedData.value.salt ?? ""); const loadResult = await this.load(encryptionKey); if (!loadResult.failed) { From 172e23ecd2bf68ae6b29c9117b50aa33c203a98d Mon Sep 17 00:00:00 2001 From: Daniel Huber Date: Sun, 24 Jul 2022 22:37:58 +0200 Subject: [PATCH 09/13] Fix linter warning due to unused import --- nodecg-io-core/dashboard/crypto.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/nodecg-io-core/dashboard/crypto.ts b/nodecg-io-core/dashboard/crypto.ts index 67f15b657..c0a8a0bb9 100644 --- a/nodecg-io-core/dashboard/crypto.ts +++ b/nodecg-io-core/dashboard/crypto.ts @@ -3,7 +3,6 @@ import { EncryptedData, decryptData, deriveEncryptionKey, - reEncryptData, ensureEncryptionSaltIsSet, } from "nodecg-io-core/extension/persistenceManager"; import { EventEmitter } from "events"; From f93a6feea3ef0dbaa871fa9e1cf1f5a59729fbdd Mon Sep 17 00:00:00 2001 From: Daniel Huber Date: Mon, 15 Aug 2022 11:13:54 +0200 Subject: [PATCH 10/13] Change key derivation function from PBKDF2 to argon2id --- nodecg-io-core/dashboard/crypto.ts | 6 +- nodecg-io-core/dashboard/esbuild.config.js | 31 +++++++ .../extension/persistenceManager.ts | 66 +++++++-------- nodecg-io-core/package.json | 2 + package-lock.json | 83 ++++++++++++++++--- 5 files changed, 136 insertions(+), 52 deletions(-) diff --git a/nodecg-io-core/dashboard/crypto.ts b/nodecg-io-core/dashboard/crypto.ts index c0a8a0bb9..cfba2faae 100644 --- a/nodecg-io-core/dashboard/crypto.ts +++ b/nodecg-io-core/dashboard/crypto.ts @@ -3,7 +3,7 @@ import { EncryptedData, decryptData, deriveEncryptionKey, - ensureEncryptionSaltIsSet, + getEncryptionSalt, } from "nodecg-io-core/extension/persistenceManager"; import { EventEmitter } from "events"; import { ObjectMap, ServiceInstance, ServiceDependency, Service } from "nodecg-io-core/extension/service"; @@ -72,8 +72,8 @@ export async function setPassword(pw: string): Promise { encryptedData.value = {}; } - ensureEncryptionSaltIsSet(encryptedData.value, pw); - encryptionKey = deriveEncryptionKey(pw, encryptedData.value.salt ?? ""); + const salt = await getEncryptionSalt(encryptedData.value, pw); + encryptionKey = await deriveEncryptionKey(pw, salt); // Load framework, returns false if not already loaded and password/encryption key is wrong if ((await loadFramework()) === false) return false; diff --git a/nodecg-io-core/dashboard/esbuild.config.js b/nodecg-io-core/dashboard/esbuild.config.js index c1e2ad19d..5d8c67f6f 100644 --- a/nodecg-io-core/dashboard/esbuild.config.js +++ b/nodecg-io-core/dashboard/esbuild.config.js @@ -11,6 +11,32 @@ const fs = require("fs"); const args = new Set(process.argv.slice(2)); const prod = process.env.NODE_ENV === "production"; +// esbuild plugin to bundle wasm modules as base64 encoded strings +// inside the generate js bundle. +// This is used for the argon2-browser wasm module. +// This is documented here: https://github.com/evanw/esbuild/issues/408#issuecomment-757555771 +const wasmPlugin = { + name: 'wasm', + setup(build) { + const namespace = "wasm-binary"; + + build.onResolve({ filter: /\.wasm$/ }, args => { + if (args.resolveDir === '') { + return // Ignore unresolvable paths + } + return { + path: path.isAbsolute(args.path) ? args.path : path.join(args.resolveDir, args.path), + namespace, + } + }) + + build.onLoad({ filter: /.*/, namespace }, async (args) => ({ + contents: await fs.promises.readFile(args.path), + loader: 'base64', + })) + }, +}; + const entryPoints = [ "monaco-editor/esm/vs/language/json/json.worker.js", "monaco-editor/esm/vs/editor/editor.worker.js", @@ -90,6 +116,11 @@ const BuildOptions = { * invalidate the build. */ watch: args.has("--watch"), + // argon2-browser has some imports to fs and path that only get actually imported when running in node.js + // because these code paths aren't executed we can just ignore the error that they don't exist in browser environments. + // See https://github.com/antelle/argon2-browser/issues/79 and https://github.com/antelle/argon2-browser/issues/26 + external: ["fs", "path"], + plugins: [wasmPlugin], }; esbuild diff --git a/nodecg-io-core/extension/persistenceManager.ts b/nodecg-io-core/extension/persistenceManager.ts index 1208d7710..7304b2763 100644 --- a/nodecg-io-core/extension/persistenceManager.ts +++ b/nodecg-io-core/extension/persistenceManager.ts @@ -2,6 +2,7 @@ import { NodeCG, ReplicantServer } from "nodecg-types/types/server"; import { InstanceManager } from "./instanceManager"; import { BundleManager } from "./bundleManager"; import crypto from "crypto-js"; +import * as argon2 from "argon2-browser"; import { emptySuccess, error, Result, success } from "./utils/result"; import { ObjectMap, ServiceDependency, ServiceInstance } from "./service"; import { ServiceManager } from "./serviceManager"; @@ -28,13 +29,13 @@ export interface PersistentData { * Salt and iv are managed by crypto.js and all AES defaults with a password are used (PBKDF1 using 1 MD5 iteration). * All this happens in the nodecg-io-core extension and the password is sent using NodeCG Messages. * - * For nodecg-io >= 0.3 this was changed. PBKDF2 using SHA256 is directly run inside the browser when logging in. + * For nodecg-io >= 0.3 this was changed. A encryption key is derived using argon2id directly inside the browser when logging in. * Only the derived AES encryption key is sent to the extension using NodeCG messages. * That way analyzed network traffic and malicious bundles that listen for the same NodeCG message only allow getting * the encryption key and not the plain text password that may be used somewhere else. * * Still with this security upgrade you should only use trusted bundles with your NodeCG installation - * and use https if your using the dashboard over a untrusted network. + * and use https if you're using the dashboard over a untrusted network. * */ export interface EncryptedData { @@ -101,36 +102,27 @@ export function encryptData(data: PersistentData, encryptionKey: crypto.lib.Word * Derives a key suitable for encrypting the config from the given password. * * @param password the password from which the encryption key will be derived. - * @param salt the salt that is used for key derivation. + * @param salt the hex encoded salt that is used for key derivation. * @returns a hex encoded string of the derived key. */ -export function deriveEncryptionKey(password: string, salt: string): string { - const saltWordArray = crypto.enc.Hex.parse(salt); - - return crypto - .PBKDF2(password, saltWordArray, { - // Generate a 256 bit long key for AES-256. - keySize: 256 / 32, - // Iterations should ideally be as high as possible. - // OWASP recommends 310.000 iterations for PBKDF2 with SHA-256 [https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2]. - // The problem that we have here is that this is run inside the browser - // and we must use the JavaScript implementation which is slow. - // There is the SubtleCrypto API in browsers that is implemented in native code inside the browser and can use cryptographic CPU extensions. - // However SubtleCrypto is only available in secure contexts (https) so we cannot use it - // because nodecg-io should be usable on e.g. raspberry pi on a local trusted network. - // So were left with only 5000 iterations which were determined - // by checking how many iterations are possible on a AMD Ryzen 5 1600 in a single second - // which should be acceptable time for logging in. Slower CPUs will take longer, - // so I didn't want to increase this any further. - - // For comparison: the crypto.js internal key generation function that was used in nodecg.io <0.3 configs - // used PBKDF1 based on a single MD5 iteration (yes, that is really the default in crypto.js...). - // So this is still a big improvement in comparison to the old config format. - iterations: 5000, - // Use SHA-256 as the hashing algorithm. crypto.js defaults to SHA-1 which is less secure. - hasher: crypto.algo.SHA256, - }) - .toString(crypto.enc.Hex); +export async function deriveEncryptionKey(password: string, salt: string): Promise { + const saltBytes = Uint8Array.from(salt.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) ?? []); + + const hash = await argon2.hash({ + pass: password, + salt: saltBytes, + // OWASP reccomends either t=1,m=37MiB or t=2,m=37MiB for argon2id: + // https://www.owasp.org/index.php/Password_Storage_Cheat_Sheet#Argon2id + // On a Ryzen 5 5500u a single iteration is about 220 ms. Two iterations would make that about 440 ms, which is still fine. + // This is run inside the browser when logging in, therefore 37 MiB is acceptable too. + // To future proof this we use 37 MiB ram and 2 iterations. + time: 2, + mem: 37 * 1024, + hashLen: 32, // Output size: 32 bytes = 256 bits as a key for AES-256 + type: argon2.ArgonType.Argon2id, + }); + + return hash.hashHex; } /** @@ -166,17 +158,18 @@ export function reEncryptData( * The salt attribute is not set when either this is the first start of nodecg-io * or if this is a old config from nodecg-io <= 0.2. * - * If this is a new configuration a new salt will be generated and set inside the EncryptedData object. + * If this is a new configuration a new salt will be generated, set inside the EncryptedData object and returned. * If this is a old configuration from nodecg-io <= 0.2 it will be migrated to the new format as well. * * @param data the encrypted data where the salt should be ensured to be available * @param password the password of the encrypted data. Used if this config needs to be migrated + * @return returns the either retrieved or generated salt */ -export function ensureEncryptionSaltIsSet(data: EncryptedData, password: string): void { +export async function getEncryptionSalt(data: EncryptedData, password: string): Promise { if (data.salt !== undefined) { // We already have a salt, so we have the new (nodecg-io >=0.3) format too. // We don't need to do anything then. - return; + return data.salt; } // No salt is present, which is the case for the nodecg-io <=0.2 configs @@ -191,7 +184,7 @@ export function ensureEncryptionSaltIsSet(data: EncryptedData, password: string) // This means that this is a old config (nodecg-io <=0.2), that we need to migrate to the new format. // Re-encrypt the configuration using our own derived key instead of the password. - const newEncryptionKey = deriveEncryptionKey(password, salt); + const newEncryptionKey = await deriveEncryptionKey(password, salt); const newEncryptionKeyArr = crypto.enc.Hex.parse(newEncryptionKey); const res = reEncryptData(data, password, newEncryptionKeyArr); if (res.failed) { @@ -200,6 +193,7 @@ export function ensureEncryptionSaltIsSet(data: EncryptedData, password: string) } data.salt = salt; + return salt; } /** @@ -455,8 +449,8 @@ export class PersistenceManager { try { this.nodecg.log.info("Attempting to automatically login..."); - ensureEncryptionSaltIsSet(this.encryptedData.value, password); - const encryptionKey = deriveEncryptionKey(password, this.encryptedData.value.salt ?? ""); + const salt = await getEncryptionSalt(this.encryptedData.value, password); + const encryptionKey = await deriveEncryptionKey(password, salt); const loadResult = await this.load(encryptionKey); if (!loadResult.failed) { diff --git a/nodecg-io-core/package.json b/nodecg-io-core/package.json index 947a7b8a3..60487adc3 100644 --- a/nodecg-io-core/package.json +++ b/nodecg-io-core/package.json @@ -44,6 +44,7 @@ }, "license": "MIT", "devDependencies": { + "@types/argon2-browser": "^1.18.1", "@types/crypto-js": "^4.1.1", "@types/jest": "^28.1.6", "@types/node": "^18.0.3", @@ -54,6 +55,7 @@ }, "dependencies": { "ajv": "^8.11.0", + "argon2-browser": "^1.18.0", "crypto-js": "^4.1.1", "tslib": "^2.4.0" } diff --git a/package-lock.json b/package-lock.json index 4a9595b82..501f6c94b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,6 @@ "samples/*", "utils/*" ], - "dependencies": { - "@types/jest": "^28.1.6" - }, "devDependencies": { "@manypkg/get-packages": "^1.1.3", "@typescript-eslint/eslint-plugin": "^5.30.6", @@ -1144,6 +1141,7 @@ "version": "28.1.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-28.1.3.tgz", "integrity": "sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==", + "dev": true, "dependencies": { "@sinclair/typebox": "^0.24.1" }, @@ -1752,7 +1750,8 @@ "node_modules/@sinclair/typebox": { "version": "0.24.20", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.20.tgz", - "integrity": "sha512-kVaO5aEFZb33nPMTZBxiPEkY+slxiPtqC7QX8f9B3eGOMBvEfuMfxp9DSTTCsRJPumPKjrge4yagyssO4q6qzQ==" + "integrity": "sha512-kVaO5aEFZb33nPMTZBxiPEkY+slxiPtqC7QX8f9B3eGOMBvEfuMfxp9DSTTCsRJPumPKjrge4yagyssO4q6qzQ==", + "dev": true }, "node_modules/@sinonjs/commons": { "version": "1.8.3", @@ -1951,6 +1950,12 @@ "@twurple/auth": "^5.0.0" } }, + "node_modules/@types/argon2-browser": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/@types/argon2-browser/-/argon2-browser-1.18.1.tgz", + "integrity": "sha512-PZffP/CqH9m2kovDSRQMfMMxUC3V98I7i7/caa0RB0/nvsXzYbL9bKyqZpNMFmLFGZslROlG1R60ONt7abrwlA==", + "dev": true + }, "node_modules/@types/babel__core": { "version": "7.1.19", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.19.tgz", @@ -2101,6 +2106,7 @@ "version": "28.1.6", "resolved": "https://registry.npmjs.org/@types/jest/-/jest-28.1.6.tgz", "integrity": "sha512-0RbGAFMfcBJKOmqRazM8L98uokwuwD5F8rHrv/ZMbrZBwVOWZUyPG6VFNscjYr/vjM3Vu4fRrCPbOs42AfemaQ==", + "dev": true, "dependencies": { "jest-matcher-utils": "^28.0.0", "pretty-format": "^28.0.0" @@ -2887,6 +2893,7 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2950,6 +2957,11 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/argon2-browser": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/argon2-browser/-/argon2-browser-1.18.0.tgz", + "integrity": "sha512-ImVAGIItnFnvET1exhsQB7apRztcoC5TnlSqernMJDUjbc/DLq3UEYeXFrLPrlaIl8cVfwnXb6wX2KpFf2zxHw==" + }, "node_modules/argparse": { "version": "2.0.1", "dev": true, @@ -3521,6 +3533,7 @@ }, "node_modules/chalk": { "version": "4.1.2", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -3535,6 +3548,7 @@ }, "node_modules/chalk/node_modules/supports-color": { "version": "7.2.0", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -3813,6 +3827,7 @@ }, "node_modules/color-convert": { "version": "2.0.1", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3823,6 +3838,7 @@ }, "node_modules/color-name": { "version": "1.1.4", + "dev": true, "license": "MIT" }, "node_modules/colorette": { @@ -4145,6 +4161,7 @@ "version": "28.1.1", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-28.1.1.tgz", "integrity": "sha512-FU0iFaH/E23a+a718l8Qa/19bF9p06kgE0KipMOMadwa3SjnaElKzPaUC0vnibs6/B/9ni97s61mcejk8W1fQw==", + "dev": true, "engines": { "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } @@ -5839,6 +5856,7 @@ }, "node_modules/has-flag": { "version": "4.0.0", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6859,6 +6877,7 @@ "version": "28.1.3", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-28.1.3.tgz", "integrity": "sha512-8RqP1B/OXzjjTWkqMX67iqgwBVJRgCyKD3L9nq+6ZqJMdvjE8RgHktqZ6jNrkdMT+dJuYNI3rhQpxaz7drJHfw==", + "dev": true, "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^28.1.1", @@ -6916,6 +6935,7 @@ }, "node_modules/jest-get-type": { "version": "28.0.2", + "dev": true, "license": "MIT", "engines": { "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" @@ -6963,6 +6983,7 @@ "version": "28.1.3", "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-28.1.3.tgz", "integrity": "sha512-kQeJ7qHemKfbzKoGjHHrRKH6atgxMk8Enkk2iPQ3XwO6oE/KYD8lMYOziCkeSB9G4adPM4nR1DE8Tf5JeWH6Bw==", + "dev": true, "dependencies": { "chalk": "^4.0.0", "jest-diff": "^28.1.3", @@ -7651,6 +7672,7 @@ }, "node_modules/midi": { "version": "2.0.0", + "hasInstallScript": true, "license": "MIT", "dependencies": { "bindings": "~1.5.0", @@ -9420,6 +9442,7 @@ "version": "28.1.3", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz", "integrity": "sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==", + "dev": true, "dependencies": { "@jest/schemas": "^28.1.3", "ansi-regex": "^5.0.1", @@ -9432,6 +9455,7 @@ }, "node_modules/pretty-format/node_modules/ansi-styles": { "version": "5.2.0", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -9639,6 +9663,7 @@ }, "node_modules/react-is": { "version": "18.2.0", + "dev": true, "license": "MIT" }, "node_modules/read-yaml-file": { @@ -11244,6 +11269,7 @@ }, "node_modules/usocket": { "version": "0.3.0", + "hasInstallScript": true, "license": "ISC", "optional": true, "dependencies": { @@ -11564,10 +11590,12 @@ "license": "MIT", "dependencies": { "ajv": "^8.11.0", + "argon2-browser": "^1.18.0", "crypto-js": "^4.1.1", "tslib": "^2.4.0" }, "devDependencies": { + "@types/argon2-browser": "^1.18.1", "@types/crypto-js": "^4.1.1", "@types/jest": "^28.1.6", "@types/node": "^18.0.3", @@ -12473,7 +12501,7 @@ "license": "MIT", "dependencies": { "nodecg-io-core": "^0.3.0", - "reddit-ts": "git+ssh://git@github.com/noeppi-noeppi/npm-reddit-ts.git#40a1ff7c115ab4bf860d13179ebf28c6e9e69286" + "reddit-ts": "https://github.com/noeppi-noeppi/npm-reddit-ts.git#build" }, "devDependencies": { "@types/node": "^18.0.3", @@ -13705,6 +13733,7 @@ "version": "28.1.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-28.1.3.tgz", "integrity": "sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==", + "dev": true, "requires": { "@sinclair/typebox": "^0.24.1" } @@ -14080,7 +14109,8 @@ "@sinclair/typebox": { "version": "0.24.20", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.20.tgz", - "integrity": "sha512-kVaO5aEFZb33nPMTZBxiPEkY+slxiPtqC7QX8f9B3eGOMBvEfuMfxp9DSTTCsRJPumPKjrge4yagyssO4q6qzQ==" + "integrity": "sha512-kVaO5aEFZb33nPMTZBxiPEkY+slxiPtqC7QX8f9B3eGOMBvEfuMfxp9DSTTCsRJPumPKjrge4yagyssO4q6qzQ==", + "dev": true }, "@sinonjs/commons": { "version": "1.8.3", @@ -14223,6 +14253,12 @@ "tslib": "^2.0.3" } }, + "@types/argon2-browser": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/@types/argon2-browser/-/argon2-browser-1.18.1.tgz", + "integrity": "sha512-PZffP/CqH9m2kovDSRQMfMMxUC3V98I7i7/caa0RB0/nvsXzYbL9bKyqZpNMFmLFGZslROlG1R60ONt7abrwlA==", + "dev": true + }, "@types/babel__core": { "version": "7.1.19", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.19.tgz", @@ -14357,6 +14393,7 @@ "version": "28.1.6", "resolved": "https://registry.npmjs.org/@types/jest/-/jest-28.1.6.tgz", "integrity": "sha512-0RbGAFMfcBJKOmqRazM8L98uokwuwD5F8rHrv/ZMbrZBwVOWZUyPG6VFNscjYr/vjM3Vu4fRrCPbOs42AfemaQ==", + "dev": true, "requires": { "jest-matcher-utils": "^28.0.0", "pretty-format": "^28.0.0" @@ -14885,6 +14922,7 @@ }, "ansi-styles": { "version": "4.3.0", + "dev": true, "requires": { "color-convert": "^2.0.1" } @@ -14934,6 +14972,11 @@ } } }, + "argon2-browser": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/argon2-browser/-/argon2-browser-1.18.0.tgz", + "integrity": "sha512-ImVAGIItnFnvET1exhsQB7apRztcoC5TnlSqernMJDUjbc/DLq3UEYeXFrLPrlaIl8cVfwnXb6wX2KpFf2zxHw==" + }, "argparse": { "version": "2.0.1", "dev": true @@ -15308,6 +15351,7 @@ }, "chalk": { "version": "4.1.2", + "dev": true, "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -15315,6 +15359,7 @@ "dependencies": { "supports-color": { "version": "7.2.0", + "dev": true, "requires": { "has-flag": "^4.0.0" } @@ -15512,12 +15557,14 @@ }, "color-convert": { "version": "2.0.1", + "dev": true, "requires": { "color-name": "~1.1.4" } }, "color-name": { - "version": "1.1.4" + "version": "1.1.4", + "dev": true }, "colorette": { "version": "2.0.16" @@ -15746,7 +15793,8 @@ "diff-sequences": { "version": "28.1.1", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-28.1.1.tgz", - "integrity": "sha512-FU0iFaH/E23a+a718l8Qa/19bF9p06kgE0KipMOMadwa3SjnaElKzPaUC0vnibs6/B/9ni97s61mcejk8W1fQw==" + "integrity": "sha512-FU0iFaH/E23a+a718l8Qa/19bF9p06kgE0KipMOMadwa3SjnaElKzPaUC0vnibs6/B/9ni97s61mcejk8W1fQw==", + "dev": true }, "dir-glob": { "version": "3.0.1", @@ -16830,7 +16878,8 @@ "version": "1.1.0" }, "has-flag": { - "version": "4.0.0" + "version": "4.0.0", + "dev": true }, "has-property-descriptors": { "version": "1.0.0", @@ -17471,6 +17520,7 @@ "version": "28.1.3", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-28.1.3.tgz", "integrity": "sha512-8RqP1B/OXzjjTWkqMX67iqgwBVJRgCyKD3L9nq+6ZqJMdvjE8RgHktqZ6jNrkdMT+dJuYNI3rhQpxaz7drJHfw==", + "dev": true, "requires": { "chalk": "^4.0.0", "diff-sequences": "^28.1.1", @@ -17515,7 +17565,8 @@ } }, "jest-get-type": { - "version": "28.0.2" + "version": "28.0.2", + "dev": true }, "jest-haste-map": { "version": "28.1.3", @@ -17551,6 +17602,7 @@ "version": "28.1.3", "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-28.1.3.tgz", "integrity": "sha512-kQeJ7qHemKfbzKoGjHHrRKH6atgxMk8Enkk2iPQ3XwO6oE/KYD8lMYOziCkeSB9G4adPM4nR1DE8Tf5JeWH6Bw==", + "dev": true, "requires": { "chalk": "^4.0.0", "jest-diff": "^28.1.3", @@ -18741,10 +18793,12 @@ "nodecg-io-core": { "version": "file:nodecg-io-core", "requires": { + "@types/argon2-browser": "^1.18.1", "@types/crypto-js": "^4.1.1", "@types/jest": "^28.1.6", "@types/node": "^18.0.3", "ajv": "^8.11.0", + "argon2-browser": "^1.18.0", "crypto-js": "^4.1.1", "jest": "^28.1.3", "nodecg-types": "^1.8.3", @@ -19005,7 +19059,7 @@ "nodecg-io-core": "^0.3.0", "nodecg-io-tsconfig": "^1.0.0", "nodecg-types": "^1.8.3", - "reddit-ts": "git+ssh://git@github.com/noeppi-noeppi/npm-reddit-ts.git#40a1ff7c115ab4bf860d13179ebf28c6e9e69286", + "reddit-ts": "https://github.com/noeppi-noeppi/npm-reddit-ts.git#build", "typescript": "^4.7.4" } }, @@ -19730,6 +19784,7 @@ "version": "28.1.3", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz", "integrity": "sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==", + "dev": true, "requires": { "@jest/schemas": "^28.1.3", "ansi-regex": "^5.0.1", @@ -19738,7 +19793,8 @@ }, "dependencies": { "ansi-styles": { - "version": "5.2.0" + "version": "5.2.0", + "dev": true } } }, @@ -19872,7 +19928,8 @@ } }, "react-is": { - "version": "18.2.0" + "version": "18.2.0", + "dev": true }, "read-yaml-file": { "version": "1.1.0", From 74099d4870727fe9687676301afd9adcd3ac15a6 Mon Sep 17 00:00:00 2001 From: Daniel Huber Date: Wed, 24 Aug 2022 15:22:30 +0200 Subject: [PATCH 11/13] Get argon2-browser fork for node.js fix and fix test errors --- nodecg-io-core/dashboard/esbuild.config.js | 27 ------------------- .../extension/__tests__/persistenceManager.ts | 13 ++++++--- .../extension/persistenceManager.ts | 6 ++--- nodecg-io-core/package.json | 2 +- package-lock.json | 16 +++++------ 5 files changed, 22 insertions(+), 42 deletions(-) diff --git a/nodecg-io-core/dashboard/esbuild.config.js b/nodecg-io-core/dashboard/esbuild.config.js index 5d8c67f6f..989818477 100644 --- a/nodecg-io-core/dashboard/esbuild.config.js +++ b/nodecg-io-core/dashboard/esbuild.config.js @@ -11,32 +11,6 @@ const fs = require("fs"); const args = new Set(process.argv.slice(2)); const prod = process.env.NODE_ENV === "production"; -// esbuild plugin to bundle wasm modules as base64 encoded strings -// inside the generate js bundle. -// This is used for the argon2-browser wasm module. -// This is documented here: https://github.com/evanw/esbuild/issues/408#issuecomment-757555771 -const wasmPlugin = { - name: 'wasm', - setup(build) { - const namespace = "wasm-binary"; - - build.onResolve({ filter: /\.wasm$/ }, args => { - if (args.resolveDir === '') { - return // Ignore unresolvable paths - } - return { - path: path.isAbsolute(args.path) ? args.path : path.join(args.resolveDir, args.path), - namespace, - } - }) - - build.onLoad({ filter: /.*/, namespace }, async (args) => ({ - contents: await fs.promises.readFile(args.path), - loader: 'base64', - })) - }, -}; - const entryPoints = [ "monaco-editor/esm/vs/language/json/json.worker.js", "monaco-editor/esm/vs/editor/editor.worker.js", @@ -120,7 +94,6 @@ const BuildOptions = { // because these code paths aren't executed we can just ignore the error that they don't exist in browser environments. // See https://github.com/antelle/argon2-browser/issues/79 and https://github.com/antelle/argon2-browser/issues/26 external: ["fs", "path"], - plugins: [wasmPlugin], }; esbuild diff --git a/nodecg-io-core/extension/__tests__/persistenceManager.ts b/nodecg-io-core/extension/__tests__/persistenceManager.ts index f8d839093..bbdf72664 100644 --- a/nodecg-io-core/extension/__tests__/persistenceManager.ts +++ b/nodecg-io-core/extension/__tests__/persistenceManager.ts @@ -17,8 +17,8 @@ describe("PersistenceManager", () => { const validPassword = "myPassword"; const invalidPassword = "myInvalidPassword"; const salt = crypto.lib.WordArray.random(128 / 8).toString(); - const validEncryptionKey = deriveEncryptionKey(validPassword, salt).toString(); - const invalidEncryptionKey = deriveEncryptionKey(invalidPassword, salt).toString(); + let validEncryptionKey = ""; // Generated in beforeEach + let invalidEncryptionKey = ""; const nodecg = new MockNodeCG(); const serviceManager = new ServiceManager(nodecg); @@ -30,7 +30,12 @@ describe("PersistenceManager", () => { const encryptedDataReplicant = nodecg.Replicant("encryptedConfig"); let persistenceManager: PersistenceManager; - beforeEach(() => { + beforeEach(async () => { + if (!validEncryptionKey || !invalidEncryptionKey) { + validEncryptionKey = await deriveEncryptionKey(validPassword, salt); + invalidEncryptionKey = await deriveEncryptionKey(invalidPassword, salt); + } + encryptedDataReplicant.removeAllListeners(); encryptedDataReplicant.value = {}; @@ -310,6 +315,8 @@ describe("PersistenceManager", () => { else return error("encryption key invalid"); }); nodecgBundleReplicant.value = bundleRepValue ?? [nodecg.bundleName]; + // Wait for automatic login to trigger + await new Promise((res) => setTimeout(res, 500)); } beforeEach(() => { diff --git a/nodecg-io-core/extension/persistenceManager.ts b/nodecg-io-core/extension/persistenceManager.ts index 7304b2763..961b2a5df 100644 --- a/nodecg-io-core/extension/persistenceManager.ts +++ b/nodecg-io-core/extension/persistenceManager.ts @@ -459,15 +459,15 @@ export class PersistenceManager { throw new Error(loadResult.errorMessage); } } catch (err) { - const logMesssage = `Failed to automatically login: ${err}`; + const logMessage = `Failed to automatically login: ${err}`; if (this.isLoaded()) { // load() threw an error but nodecg-io is currently loaded nonetheless. // Anyway, nodecg-io is loaded which is what we wanted - this.nodecg.log.warn(logMesssage); + this.nodecg.log.warn(logMessage); } else { // Something went wrong and nodecg-io is not loaded. // This is a real error, the password might be wrong or some other issue. - this.nodecg.log.error(logMesssage); + this.nodecg.log.error(logMessage); } } } diff --git a/nodecg-io-core/package.json b/nodecg-io-core/package.json index 60487adc3..b61be4591 100644 --- a/nodecg-io-core/package.json +++ b/nodecg-io-core/package.json @@ -55,7 +55,7 @@ }, "dependencies": { "ajv": "^8.11.0", - "argon2-browser": "^1.18.0", + "argon2-browser": "https://github.com/daniel0611/argon2-browser/releases/download/1.19.0/argon2-browser-1.19.0.tgz", "crypto-js": "^4.1.1", "tslib": "^2.4.0" } diff --git a/package-lock.json b/package-lock.json index 501f6c94b..181730100 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2958,9 +2958,10 @@ } }, "node_modules/argon2-browser": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/argon2-browser/-/argon2-browser-1.18.0.tgz", - "integrity": "sha512-ImVAGIItnFnvET1exhsQB7apRztcoC5TnlSqernMJDUjbc/DLq3UEYeXFrLPrlaIl8cVfwnXb6wX2KpFf2zxHw==" + "version": "1.19.0", + "resolved": "https://github.com/daniel0611/argon2-browser/releases/download/1.19.0/argon2-browser-1.19.0.tgz", + "integrity": "sha512-etPQaWx84MVYhOGRdYWrPmfDhtxHfcNRnsdiHaBFOXpt6lTuvxXdPzTG/Z/I8P+/wxqLDzNE8PgCbKoyedVWVQ==", + "license": "MIT" }, "node_modules/argparse": { "version": "2.0.1", @@ -11590,7 +11591,7 @@ "license": "MIT", "dependencies": { "ajv": "^8.11.0", - "argon2-browser": "^1.18.0", + "argon2-browser": "https://github.com/daniel0611/argon2-browser/releases/download/1.19.0/argon2-browser-1.19.0.tgz", "crypto-js": "^4.1.1", "tslib": "^2.4.0" }, @@ -14973,9 +14974,8 @@ } }, "argon2-browser": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/argon2-browser/-/argon2-browser-1.18.0.tgz", - "integrity": "sha512-ImVAGIItnFnvET1exhsQB7apRztcoC5TnlSqernMJDUjbc/DLq3UEYeXFrLPrlaIl8cVfwnXb6wX2KpFf2zxHw==" + "version": "https://github.com/daniel0611/argon2-browser/releases/download/1.19.0/argon2-browser-1.19.0.tgz", + "integrity": "sha512-etPQaWx84MVYhOGRdYWrPmfDhtxHfcNRnsdiHaBFOXpt6lTuvxXdPzTG/Z/I8P+/wxqLDzNE8PgCbKoyedVWVQ==" }, "argparse": { "version": "2.0.1", @@ -18798,7 +18798,7 @@ "@types/jest": "^28.1.6", "@types/node": "^18.0.3", "ajv": "^8.11.0", - "argon2-browser": "^1.18.0", + "argon2-browser": "https://github.com/daniel0611/argon2-browser/releases/download/1.19.0/argon2-browser-1.19.0.tgz", "crypto-js": "^4.1.1", "jest": "^28.1.3", "nodecg-types": "^1.8.3", From a366c1f310140a5fa40312e1083badaa509a739e Mon Sep 17 00:00:00 2001 From: Daniel Huber Date: Thu, 8 Sep 2022 10:42:37 +0200 Subject: [PATCH 12/13] Add unit test for config format migration --- nodecg-io-core/extension/__tests__/mocks.ts | 20 ++++++- .../extension/__tests__/persistenceManager.ts | 59 ++++++++++++++++++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/nodecg-io-core/extension/__tests__/mocks.ts b/nodecg-io-core/extension/__tests__/mocks.ts index eb6270784..441869737 100644 --- a/nodecg-io-core/extension/__tests__/mocks.ts +++ b/nodecg-io-core/extension/__tests__/mocks.ts @@ -1,4 +1,4 @@ -import { ObjectMap, ServiceInstance } from "../service"; +import { ObjectMap, ServiceInstance, Service } from "../service"; import type { NodeCG, ReplicantOptions, Replicant, Logger } from "nodecg-types/types/server"; import { EventEmitter } from "events"; @@ -144,6 +144,24 @@ export const testService = { }, }; +export const websocketServerService: Service<{ port: number }, void> = { + serviceType: "websocket-server", + validateConfig: jest.fn(), + createClient: jest.fn(), + stopClient: jest.fn(), + reCreateClientToRemoveHandlers: false, + requiresNoConfig: false, + schema: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + port: { + type: "integer", + }, + }, + }, +}; + export const testServiceInstance: ServiceInstance string> = { serviceType: testService.serviceType, config: "hello world", diff --git a/nodecg-io-core/extension/__tests__/persistenceManager.ts b/nodecg-io-core/extension/__tests__/persistenceManager.ts index bbdf72664..8b048586f 100644 --- a/nodecg-io-core/extension/__tests__/persistenceManager.ts +++ b/nodecg-io-core/extension/__tests__/persistenceManager.ts @@ -5,13 +5,21 @@ import { decryptData, deriveEncryptionKey, EncryptedData, + getEncryptionSalt, PersistenceManager, PersistentData, } from "../persistenceManager"; import { ServiceManager } from "../serviceManager"; import { ServiceProvider } from "../serviceProvider"; import { emptySuccess, error } from "../utils/result"; -import { MockNodeCG, testBundle, testInstance, testService, testServiceInstance } from "./mocks"; +import { + MockNodeCG, + testBundle, + testInstance, + testService, + testServiceInstance, + websocketServerService, +} from "./mocks"; describe("PersistenceManager", () => { const validPassword = "myPassword"; @@ -23,6 +31,7 @@ describe("PersistenceManager", () => { const nodecg = new MockNodeCG(); const serviceManager = new ServiceManager(nodecg); serviceManager.registerService(testService); + serviceManager.registerService(websocketServerService); let bundleManager: BundleManager; let instanceManager: InstanceManager; @@ -262,6 +271,54 @@ describe("PersistenceManager", () => { expect(deps?.[0]).toBeDefined(); expect(deps?.[0]?.serviceInstance).toBeUndefined(); }); + + // Config migration + + test("should be able to migrate nodecg-io <= 0.2 config to a newer nodecg-io >= 0.3 config", async () => { + // Old nodecg-io 0.2 config with the following values: + // password: 654321 + // services: + // ws -> websocket-server + // port: 5678 + // bundles: + // ws-server-test: + // websocket-server -> ws + const oldConfig: EncryptedData = { + cipherText: + "U2FsdGVkX19/ECpN7V/FUE4WQ3Fp5mb/0y06HHG6TVw7oRdQfMygbnfP2VtgJ7MVx/Uw7U5wI7jlwSNHN/3eG0rY0Du2E5jJbHr3OHl5OfgsZKWbGVoEzuCtEsLjSz1FeEf4C2VIvjWeJWTKBmSm+DVitNRwwM6Ex+f97gI0HbWjU9qVhBsw05RY9vA4/XpsucRdEh5Q6RIDnVn3Gj75OlB7IlsygCv2IzxnGqx4vr3k8J4kQo8DBhyOdxQtYCkHSpuM0d3cBOMAgySZWcw2EU5PN7F6wMmeR5Zko10LhNuMntSD+Zw6yZFeFPVDRM4OhVv8146zZX5+w3XJX2KZjQ==", + }; + encryptedDataReplicant.value = oldConfig; + bundleManager.registerServiceDependency("ws-server-test", websocketServerService); + + // Invalid keys should also error with olds configs + const invalidResult = await persistenceManager.load(invalidEncryptionKey); + expect(invalidResult.failed).toBe(true); + + // Loading should just work and migrate the configuration + const password = "654321"; + const salt = await getEncryptionSalt(oldConfig, password); + const encryptionKey = await deriveEncryptionKey(password, salt); + const correctPasswordResult = await persistenceManager.load(encryptionKey); + expect(correctPasswordResult.failed).toBe(false); + + // Config has been migrated: salt and iv are only present in nodecg-io >=0.3 configs + expect(oldConfig.salt).toBeDefined(); + expect(oldConfig.iv).toBeDefined(); + + // Check instances and bundles for correct data + expect(instanceManager.getServiceInstance("ws")).toBeDefined(); + expect(instanceManager.getServiceInstance("ws")?.config).toEqual({ + port: 5678, + }); + + expect(bundleManager.getBundleDependencies()["ws-server-test"]).toEqual([ + { + serviceType: "websocket-server", + serviceInstance: "ws", + provider: new ServiceProvider(), + }, + ]); + }); }); describe("save", () => { From e85a4a892b23217969bc1933d47df98b3bb5dd1b Mon Sep 17 00:00:00 2001 From: Daniel Huber <30466471+daniel0611@users.noreply.github.com> Date: Wed, 12 Oct 2022 10:04:54 +0200 Subject: [PATCH 13/13] Switch from argon2-browser to hash-wasm for argon2 implementation The latest official release argon2-browser doesn't support node.js v18+ and also has some other caveats. Because of this I've used my fork of it in 74099d4870727fe9687676301afd9adcd3ac15a6 with some patches. Instead of using my patched version this commit uses hash-wasm which also includes a implementation of argon2, works correctly with node.js v18 and works fine in the browser as well. --- nodecg-io-core/dashboard/esbuild.config.js | 4 -- .../extension/persistenceManager.ts | 17 ++++----- nodecg-io-core/package.json | 3 +- package-lock.json | 38 ++++++------------- 4 files changed, 21 insertions(+), 41 deletions(-) diff --git a/nodecg-io-core/dashboard/esbuild.config.js b/nodecg-io-core/dashboard/esbuild.config.js index 989818477..c1e2ad19d 100644 --- a/nodecg-io-core/dashboard/esbuild.config.js +++ b/nodecg-io-core/dashboard/esbuild.config.js @@ -90,10 +90,6 @@ const BuildOptions = { * invalidate the build. */ watch: args.has("--watch"), - // argon2-browser has some imports to fs and path that only get actually imported when running in node.js - // because these code paths aren't executed we can just ignore the error that they don't exist in browser environments. - // See https://github.com/antelle/argon2-browser/issues/79 and https://github.com/antelle/argon2-browser/issues/26 - external: ["fs", "path"], }; esbuild diff --git a/nodecg-io-core/extension/persistenceManager.ts b/nodecg-io-core/extension/persistenceManager.ts index 961b2a5df..8b03abb94 100644 --- a/nodecg-io-core/extension/persistenceManager.ts +++ b/nodecg-io-core/extension/persistenceManager.ts @@ -2,7 +2,7 @@ import { NodeCG, ReplicantServer } from "nodecg-types/types/server"; import { InstanceManager } from "./instanceManager"; import { BundleManager } from "./bundleManager"; import crypto from "crypto-js"; -import * as argon2 from "argon2-browser"; +import { argon2id } from "hash-wasm"; import { emptySuccess, error, Result, success } from "./utils/result"; import { ObjectMap, ServiceDependency, ServiceInstance } from "./service"; import { ServiceManager } from "./serviceManager"; @@ -108,21 +108,20 @@ export function encryptData(data: PersistentData, encryptionKey: crypto.lib.Word export async function deriveEncryptionKey(password: string, salt: string): Promise { const saltBytes = Uint8Array.from(salt.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) ?? []); - const hash = await argon2.hash({ - pass: password, + return await argon2id({ + password, salt: saltBytes, // OWASP reccomends either t=1,m=37MiB or t=2,m=37MiB for argon2id: // https://www.owasp.org/index.php/Password_Storage_Cheat_Sheet#Argon2id // On a Ryzen 5 5500u a single iteration is about 220 ms. Two iterations would make that about 440 ms, which is still fine. // This is run inside the browser when logging in, therefore 37 MiB is acceptable too. // To future proof this we use 37 MiB ram and 2 iterations. - time: 2, - mem: 37 * 1024, - hashLen: 32, // Output size: 32 bytes = 256 bits as a key for AES-256 - type: argon2.ArgonType.Argon2id, + iterations: 2, + memorySize: 37, // KiB + hashLength: 32, // Output size: 32 bytes = 256 bits as a key for AES-256 + parallelism: 1, + outputType: "hex", }); - - return hash.hashHex; } /** diff --git a/nodecg-io-core/package.json b/nodecg-io-core/package.json index b61be4591..7c0e64d1f 100644 --- a/nodecg-io-core/package.json +++ b/nodecg-io-core/package.json @@ -44,7 +44,6 @@ }, "license": "MIT", "devDependencies": { - "@types/argon2-browser": "^1.18.1", "@types/crypto-js": "^4.1.1", "@types/jest": "^28.1.6", "@types/node": "^18.0.3", @@ -55,7 +54,7 @@ }, "dependencies": { "ajv": "^8.11.0", - "argon2-browser": "https://github.com/daniel0611/argon2-browser/releases/download/1.19.0/argon2-browser-1.19.0.tgz", + "hash-wasm": "^4.9.0", "crypto-js": "^4.1.1", "tslib": "^2.4.0" } diff --git a/package-lock.json b/package-lock.json index 181730100..7396864af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1950,12 +1950,6 @@ "@twurple/auth": "^5.0.0" } }, - "node_modules/@types/argon2-browser": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/@types/argon2-browser/-/argon2-browser-1.18.1.tgz", - "integrity": "sha512-PZffP/CqH9m2kovDSRQMfMMxUC3V98I7i7/caa0RB0/nvsXzYbL9bKyqZpNMFmLFGZslROlG1R60ONt7abrwlA==", - "dev": true - }, "node_modules/@types/babel__core": { "version": "7.1.19", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.19.tgz", @@ -2957,12 +2951,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/argon2-browser": { - "version": "1.19.0", - "resolved": "https://github.com/daniel0611/argon2-browser/releases/download/1.19.0/argon2-browser-1.19.0.tgz", - "integrity": "sha512-etPQaWx84MVYhOGRdYWrPmfDhtxHfcNRnsdiHaBFOXpt6lTuvxXdPzTG/Z/I8P+/wxqLDzNE8PgCbKoyedVWVQ==", - "license": "MIT" - }, "node_modules/argparse": { "version": "2.0.1", "dev": true, @@ -5900,6 +5888,11 @@ "version": "2.0.1", "license": "ISC" }, + "node_modules/hash-wasm": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.9.0.tgz", + "integrity": "sha512-7SW7ejyfnRxuOc7ptQHSf4LDoZaWOivfzqw+5rpcQku0nHfmicPKE51ra9BiRLAmT8+gGLestr1XroUkqdjL6w==" + }, "node_modules/help-me": { "version": "3.0.0", "license": "MIT", @@ -11591,12 +11584,11 @@ "license": "MIT", "dependencies": { "ajv": "^8.11.0", - "argon2-browser": "https://github.com/daniel0611/argon2-browser/releases/download/1.19.0/argon2-browser-1.19.0.tgz", "crypto-js": "^4.1.1", + "hash-wasm": "^4.9.0", "tslib": "^2.4.0" }, "devDependencies": { - "@types/argon2-browser": "^1.18.1", "@types/crypto-js": "^4.1.1", "@types/jest": "^28.1.6", "@types/node": "^18.0.3", @@ -14254,12 +14246,6 @@ "tslib": "^2.0.3" } }, - "@types/argon2-browser": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/@types/argon2-browser/-/argon2-browser-1.18.1.tgz", - "integrity": "sha512-PZffP/CqH9m2kovDSRQMfMMxUC3V98I7i7/caa0RB0/nvsXzYbL9bKyqZpNMFmLFGZslROlG1R60ONt7abrwlA==", - "dev": true - }, "@types/babel__core": { "version": "7.1.19", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.19.tgz", @@ -14973,10 +14959,6 @@ } } }, - "argon2-browser": { - "version": "https://github.com/daniel0611/argon2-browser/releases/download/1.19.0/argon2-browser-1.19.0.tgz", - "integrity": "sha512-etPQaWx84MVYhOGRdYWrPmfDhtxHfcNRnsdiHaBFOXpt6lTuvxXdPzTG/Z/I8P+/wxqLDzNE8PgCbKoyedVWVQ==" - }, "argparse": { "version": "2.0.1", "dev": true @@ -16899,6 +16881,11 @@ "has-unicode": { "version": "2.0.1" }, + "hash-wasm": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.9.0.tgz", + "integrity": "sha512-7SW7ejyfnRxuOc7ptQHSf4LDoZaWOivfzqw+5rpcQku0nHfmicPKE51ra9BiRLAmT8+gGLestr1XroUkqdjL6w==" + }, "help-me": { "version": "3.0.0", "requires": { @@ -18793,13 +18780,12 @@ "nodecg-io-core": { "version": "file:nodecg-io-core", "requires": { - "@types/argon2-browser": "^1.18.1", "@types/crypto-js": "^4.1.1", "@types/jest": "^28.1.6", "@types/node": "^18.0.3", "ajv": "^8.11.0", - "argon2-browser": "https://github.com/daniel0611/argon2-browser/releases/download/1.19.0/argon2-browser-1.19.0.tgz", "crypto-js": "^4.1.1", + "hash-wasm": "^4.9.0", "jest": "^28.1.3", "nodecg-types": "^1.8.3", "ts-jest": "^28.0.5",