Skip to content

Commit

Permalink
Merge pull request #424 from codeoverflow-org/feat/encrypt-using-pass…
Browse files Browse the repository at this point in the history
…word-hash

Derive encryption key in dashboard using Argon2
  • Loading branch information
hlxid authored Jul 23, 2023
2 parents b66eb2e + f9db513 commit 8eea596
Show file tree
Hide file tree
Showing 7 changed files with 7,013 additions and 1,968 deletions.
65 changes: 41 additions & 24 deletions nodecg-io-core/dashboard/crypto.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { PersistentData, EncryptedData, decryptData } from "nodecg-io-core/extension/persistenceManager";
import {
PersistentData,
EncryptedData,
decryptData,
deriveEncryptionKey,
getEncryptionSalt,
} 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<EncryptedData>("encryptedConfig");
let services: Service<unknown, never>[] | undefined;
let password: string | undefined;
let encryptionKey: string | undefined;

/**
* Layer between the actual dashboard and `PersistentData`.
Expand Down Expand Up @@ -40,71 +47,81 @@ 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<boolean> {
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),
fetchServices(),
]);

password = pw;
if (encryptedData.value === undefined) {
encryptedData.value = {};
}

const salt = await getEncryptionSalt(encryptedData.value, pw);
encryptionKey = await 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;
}
}

return true;
}

export async function sendAuthenticatedMessage<V>(messageName: string, message: Partial<PasswordMessage>): Promise<V> {
if (password === undefined) throw "No password available";
export async function sendAuthenticatedMessage<V>(
messageName: string,
message: Partial<AuthenticationMessage>,
): Promise<V> {
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`.
* 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 {
let result: PersistentData | undefined = undefined;
if (password !== undefined && data.cipherText) {
const res = decryptData(data.cipherText, 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;
}
}

Expand Down Expand Up @@ -135,7 +152,7 @@ async function loadFramework(): Promise<boolean> {
if (await isLoaded()) return true;

try {
await nodecg.sendMessage("load", { password });
await nodecg.sendMessage("load", { encryptionKey });
return true;
} catch {
return false;
Expand Down
20 changes: 19 additions & 1 deletion nodecg-io-core/extension/__tests__/mocks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ObjectMap, ServiceInstance } from "../service";
import { ObjectMap, ServiceInstance, Service } from "../service";
import NodeCG from "@nodecg/types";
import { EventEmitter } from "events";

Expand Down Expand Up @@ -162,6 +162,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, () => string> = {
serviceType: testService.serviceType,
config: "hello world",
Expand Down
Loading

0 comments on commit 8eea596

Please sign in to comment.