Skip to content
This repository was archived by the owner on Apr 13, 2025. It is now read-only.

Commit bc73fc7

Browse files
committed
Derive encryption key in dashboard using PBKDF2
1 parent e43c8a1 commit bc73fc7

File tree

2 files changed

+106
-10
lines changed

2 files changed

+106
-10
lines changed

nodecg-io-core/dashboard/crypto.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1-
import { PersistentData, EncryptedData, decryptData } from "nodecg-io-core/extension/persistenceManager";
1+
import {
2+
PersistentData,
3+
EncryptedData,
4+
decryptData,
5+
deriveEncryptionSecret,
6+
reEncryptData,
7+
} from "nodecg-io-core/extension/persistenceManager";
28
import { EventEmitter } from "events";
39
import { ObjectMap, ServiceInstance, ServiceDependency, Service } from "nodecg-io-core/extension/service";
410
import { isLoaded } from "./authentication";
511
import { PasswordMessage } from "nodecg-io-core/extension/messageManager";
12+
import cryptoJS from "crypto-js";
613

714
const encryptedData = nodecg.Replicant<EncryptedData>("encryptedConfig");
815
let services: Service<unknown, never>[] | undefined;
@@ -60,7 +67,23 @@ export async function setPassword(pw: string): Promise<boolean> {
6067
fetchServices(),
6168
]);
6269

63-
password = pw;
70+
if (encryptedData.value === undefined) {
71+
encryptedData.value = {};
72+
}
73+
74+
const salt = encryptedData.value.salt ?? cryptoJS.lib.WordArray.random(128 / 8).toString(cryptoJS.enc.Hex);
75+
if (encryptedData.value.salt === undefined) {
76+
const newSecret = deriveEncryptionSecret(pw, salt);
77+
78+
if (encryptedData.value.cipherText !== undefined) {
79+
const newSecretWordArray = cryptoJS.enc.Hex.parse(newSecret);
80+
reEncryptData(encryptedData.value, pw, newSecretWordArray);
81+
}
82+
83+
encryptedData.value.salt = salt;
84+
}
85+
86+
password = deriveEncryptionSecret(pw, salt);
6487

6588
// Load framework, returns false if not already loaded and password is wrong
6689
if ((await loadFramework()) === false) return false;
@@ -99,7 +122,8 @@ export function isPasswordSet(): boolean {
99122
function updateDecryptedData(data: EncryptedData): void {
100123
let result: PersistentData | undefined = undefined;
101124
if (password !== undefined && data.cipherText) {
102-
const res = decryptData(data.cipherText, password);
125+
const passwordWordArray = cryptoJS.enc.Hex.parse(password);
126+
const res = decryptData(data.cipherText, passwordWordArray, data.iv);
103127
if (!res.failed) {
104128
result = res.result;
105129
} else {

nodecg-io-core/extension/persistenceManager.ts

Lines changed: 79 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { NodeCG, ReplicantServer } from "nodecg-types/types/server";
22
import { InstanceManager } from "./instanceManager";
33
import { BundleManager } from "./bundleManager";
4-
import * as crypto from "crypto-js";
4+
import crypto from "crypto-js";
55
import { emptySuccess, error, Result, success } from "./utils/result";
66
import { ObjectMap, ServiceDependency, ServiceInstance } from "./service";
77
import { ServiceManager } from "./serviceManager";
@@ -28,6 +28,8 @@ export interface EncryptedData {
2828
* The encrypted format of the data that needs to be stored.
2929
*/
3030
cipherText?: string;
31+
salt?: string;
32+
iv?: string;
3133
}
3234

3335
/**
@@ -37,9 +39,14 @@ export interface EncryptedData {
3739
* @param cipherText the ciphertext that needs to be decrypted.
3840
* @param password the password for the encrypted data.
3941
*/
40-
export function decryptData(cipherText: string, password: string): Result<PersistentData> {
42+
export function decryptData(
43+
cipherText: string,
44+
password: string | crypto.lib.WordArray,
45+
iv: string | undefined,
46+
): Result<PersistentData> {
4147
try {
42-
const decryptedBytes = crypto.AES.decrypt(cipherText, password);
48+
const ivWordArray = iv ? crypto.enc.Hex.parse(iv) : undefined;
49+
const decryptedBytes = crypto.AES.decrypt(cipherText, password, { iv: ivWordArray });
4350
const decryptedText = decryptedBytes.toString(crypto.enc.Utf8);
4451
const data: PersistentData = JSON.parse(decryptedText);
4552
return success(data);
@@ -48,6 +55,48 @@ export function decryptData(cipherText: string, password: string): Result<Persis
4855
}
4956
}
5057

58+
export function encryptData(data: PersistentData, password: string | crypto.lib.WordArray): [string, string] {
59+
const iv = crypto.lib.WordArray.random(16);
60+
const ivText = iv.toString();
61+
const encrypted = crypto.AES.encrypt(JSON.stringify(data), password, { iv });
62+
return [encrypted.toString(), ivText];
63+
}
64+
65+
export function deriveEncryptionSecret(password: string, salt: string | undefined): string {
66+
if (salt === undefined) {
67+
return password;
68+
}
69+
70+
const saltWordArray = crypto.enc.Hex.parse(salt);
71+
72+
return crypto
73+
.PBKDF2(password, saltWordArray, {
74+
keySize: 256 / 32,
75+
iterations: 5000,
76+
})
77+
.toString(crypto.enc.Hex);
78+
}
79+
80+
export function reEncryptData(
81+
data: EncryptedData,
82+
oldSecret: string | crypto.lib.WordArray,
83+
newSecret: string | crypto.lib.WordArray,
84+
): Result<void> {
85+
if (data.cipherText === undefined) {
86+
return error("Cannot re-encrypt empty cipher text.");
87+
}
88+
89+
const decryptedData = decryptData(data.cipherText, oldSecret, data.iv);
90+
if (decryptedData.failed) {
91+
return error(decryptedData.errorMessage);
92+
}
93+
94+
const [newCipherText, iv] = encryptData(decryptedData.result, newSecret);
95+
data.cipherText = newCipherText;
96+
data.iv = iv;
97+
return emptySuccess();
98+
}
99+
51100
/**
52101
* Manages encrypted persistence of data that is held by the instance and bundle managers.
53102
*/
@@ -116,8 +165,14 @@ export class PersistenceManager {
116165
} else {
117166
// Decrypt config
118167
this.nodecg.log.info("Decrypting and loading saved configuration.");
119-
const data = decryptData(this.encryptedData.value.cipherText, password);
168+
const passwordWordArray = crypto.enc.Hex.parse(password);
169+
const data = decryptData(
170+
this.encryptedData.value.cipherText,
171+
passwordWordArray,
172+
this.encryptedData.value.iv,
173+
);
120174
if (data.failed) {
175+
this.nodecg.log.error("Could not decrypt configuration: password is invalid.");
121176
return data;
122177
}
123178

@@ -215,8 +270,10 @@ export class PersistenceManager {
215270
};
216271

217272
// Encrypt and save data to persistent replicant.
218-
const cipherText = crypto.AES.encrypt(JSON.stringify(data), this.password);
219-
this.encryptedData.value.cipherText = cipherText.toString();
273+
const passwordWordArray = crypto.enc.Hex.parse(this.password);
274+
const [cipherText, iv] = encryptData(data, passwordWordArray);
275+
this.encryptedData.value.cipherText = cipherText;
276+
this.encryptedData.value.iv = iv;
220277
}
221278

222279
/**
@@ -292,7 +349,22 @@ export class PersistenceManager {
292349
if (bundles.length > 0) {
293350
try {
294351
this.nodecg.log.info("Attempting to automatically login...");
295-
const loadResult = await this.load(password);
352+
353+
const salt =
354+
this.encryptedData.value.salt ?? crypto.lib.WordArray.random(128 / 8).toString(crypto.enc.Hex);
355+
if (this.encryptedData.value.salt === undefined) {
356+
const newSecret = deriveEncryptionSecret(password, salt);
357+
358+
if (this.encryptedData.value.cipherText !== undefined) {
359+
const newSecretWordArray = crypto.enc.Hex.parse(newSecret);
360+
reEncryptData(this.encryptedData.value, password, newSecretWordArray);
361+
}
362+
363+
this.encryptedData.value.salt = salt;
364+
}
365+
366+
const encryptionSecret = deriveEncryptionSecret(password, salt);
367+
const loadResult = await this.load(encryptionSecret);
296368

297369
if (!loadResult.failed) {
298370
this.nodecg.log.info("Automatic login successful.");

0 commit comments

Comments
 (0)