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

Commit f93a6fe

Browse files
committed
Change key derivation function from PBKDF2 to argon2id
1 parent 172e23e commit f93a6fe

File tree

5 files changed

+136
-52
lines changed

5 files changed

+136
-52
lines changed

nodecg-io-core/dashboard/crypto.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
EncryptedData,
44
decryptData,
55
deriveEncryptionKey,
6-
ensureEncryptionSaltIsSet,
6+
getEncryptionSalt,
77
} from "nodecg-io-core/extension/persistenceManager";
88
import { EventEmitter } from "events";
99
import { ObjectMap, ServiceInstance, ServiceDependency, Service } from "nodecg-io-core/extension/service";
@@ -72,8 +72,8 @@ export async function setPassword(pw: string): Promise<boolean> {
7272
encryptedData.value = {};
7373
}
7474

75-
ensureEncryptionSaltIsSet(encryptedData.value, pw);
76-
encryptionKey = deriveEncryptionKey(pw, encryptedData.value.salt ?? "");
75+
const salt = await getEncryptionSalt(encryptedData.value, pw);
76+
encryptionKey = await deriveEncryptionKey(pw, salt);
7777

7878
// Load framework, returns false if not already loaded and password/encryption key is wrong
7979
if ((await loadFramework()) === false) return false;

nodecg-io-core/dashboard/esbuild.config.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,32 @@ const fs = require("fs");
1111
const args = new Set(process.argv.slice(2));
1212
const prod = process.env.NODE_ENV === "production";
1313

14+
// esbuild plugin to bundle wasm modules as base64 encoded strings
15+
// inside the generate js bundle.
16+
// This is used for the argon2-browser wasm module.
17+
// This is documented here: https://github.com/evanw/esbuild/issues/408#issuecomment-757555771
18+
const wasmPlugin = {
19+
name: 'wasm',
20+
setup(build) {
21+
const namespace = "wasm-binary";
22+
23+
build.onResolve({ filter: /\.wasm$/ }, args => {
24+
if (args.resolveDir === '') {
25+
return // Ignore unresolvable paths
26+
}
27+
return {
28+
path: path.isAbsolute(args.path) ? args.path : path.join(args.resolveDir, args.path),
29+
namespace,
30+
}
31+
})
32+
33+
build.onLoad({ filter: /.*/, namespace }, async (args) => ({
34+
contents: await fs.promises.readFile(args.path),
35+
loader: 'base64',
36+
}))
37+
},
38+
};
39+
1440
const entryPoints = [
1541
"monaco-editor/esm/vs/language/json/json.worker.js",
1642
"monaco-editor/esm/vs/editor/editor.worker.js",
@@ -90,6 +116,11 @@ const BuildOptions = {
90116
* invalidate the build.
91117
*/
92118
watch: args.has("--watch"),
119+
// argon2-browser has some imports to fs and path that only get actually imported when running in node.js
120+
// because these code paths aren't executed we can just ignore the error that they don't exist in browser environments.
121+
// See https://github.com/antelle/argon2-browser/issues/79 and https://github.com/antelle/argon2-browser/issues/26
122+
external: ["fs", "path"],
123+
plugins: [wasmPlugin],
93124
};
94125

95126
esbuild

nodecg-io-core/extension/persistenceManager.ts

Lines changed: 30 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NodeCG, ReplicantServer } from "nodecg-types/types/server";
22
import { InstanceManager } from "./instanceManager";
33
import { BundleManager } from "./bundleManager";
44
import crypto from "crypto-js";
5+
import * as argon2 from "argon2-browser";
56
import { emptySuccess, error, Result, success } from "./utils/result";
67
import { ObjectMap, ServiceDependency, ServiceInstance } from "./service";
78
import { ServiceManager } from "./serviceManager";
@@ -28,13 +29,13 @@ export interface PersistentData {
2829
* Salt and iv are managed by crypto.js and all AES defaults with a password are used (PBKDF1 using 1 MD5 iteration).
2930
* All this happens in the nodecg-io-core extension and the password is sent using NodeCG Messages.
3031
*
31-
* For nodecg-io >= 0.3 this was changed. PBKDF2 using SHA256 is directly run inside the browser when logging in.
32+
* For nodecg-io >= 0.3 this was changed. A encryption key is derived using argon2id directly inside the browser when logging in.
3233
* Only the derived AES encryption key is sent to the extension using NodeCG messages.
3334
* That way analyzed network traffic and malicious bundles that listen for the same NodeCG message only allow getting
3435
* the encryption key and not the plain text password that may be used somewhere else.
3536
*
3637
* Still with this security upgrade you should only use trusted bundles with your NodeCG installation
37-
* and use https if your using the dashboard over a untrusted network.
38+
* and use https if you're using the dashboard over a untrusted network.
3839
*
3940
*/
4041
export interface EncryptedData {
@@ -101,36 +102,27 @@ export function encryptData(data: PersistentData, encryptionKey: crypto.lib.Word
101102
* Derives a key suitable for encrypting the config from the given password.
102103
*
103104
* @param password the password from which the encryption key will be derived.
104-
* @param salt the salt that is used for key derivation.
105+
* @param salt the hex encoded salt that is used for key derivation.
105106
* @returns a hex encoded string of the derived key.
106107
*/
107-
export function deriveEncryptionKey(password: string, salt: string): string {
108-
const saltWordArray = crypto.enc.Hex.parse(salt);
109-
110-
return crypto
111-
.PBKDF2(password, saltWordArray, {
112-
// Generate a 256 bit long key for AES-256.
113-
keySize: 256 / 32,
114-
// Iterations should ideally be as high as possible.
115-
// OWASP recommends 310.000 iterations for PBKDF2 with SHA-256 [https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2].
116-
// The problem that we have here is that this is run inside the browser
117-
// and we must use the JavaScript implementation which is slow.
118-
// There is the SubtleCrypto API in browsers that is implemented in native code inside the browser and can use cryptographic CPU extensions.
119-
// However SubtleCrypto is only available in secure contexts (https) so we cannot use it
120-
// because nodecg-io should be usable on e.g. raspberry pi on a local trusted network.
121-
// So were left with only 5000 iterations which were determined
122-
// by checking how many iterations are possible on a AMD Ryzen 5 1600 in a single second
123-
// which should be acceptable time for logging in. Slower CPUs will take longer,
124-
// so I didn't want to increase this any further.
125-
126-
// For comparison: the crypto.js internal key generation function that was used in nodecg.io <0.3 configs
127-
// used PBKDF1 based on a single MD5 iteration (yes, that is really the default in crypto.js...).
128-
// So this is still a big improvement in comparison to the old config format.
129-
iterations: 5000,
130-
// Use SHA-256 as the hashing algorithm. crypto.js defaults to SHA-1 which is less secure.
131-
hasher: crypto.algo.SHA256,
132-
})
133-
.toString(crypto.enc.Hex);
108+
export async function deriveEncryptionKey(password: string, salt: string): Promise<string> {
109+
const saltBytes = Uint8Array.from(salt.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) ?? []);
110+
111+
const hash = await argon2.hash({
112+
pass: password,
113+
salt: saltBytes,
114+
// OWASP reccomends either t=1,m=37MiB or t=2,m=37MiB for argon2id:
115+
// https://www.owasp.org/index.php/Password_Storage_Cheat_Sheet#Argon2id
116+
// On a Ryzen 5 5500u a single iteration is about 220 ms. Two iterations would make that about 440 ms, which is still fine.
117+
// This is run inside the browser when logging in, therefore 37 MiB is acceptable too.
118+
// To future proof this we use 37 MiB ram and 2 iterations.
119+
time: 2,
120+
mem: 37 * 1024,
121+
hashLen: 32, // Output size: 32 bytes = 256 bits as a key for AES-256
122+
type: argon2.ArgonType.Argon2id,
123+
});
124+
125+
return hash.hashHex;
134126
}
135127

136128
/**
@@ -166,17 +158,18 @@ export function reEncryptData(
166158
* The salt attribute is not set when either this is the first start of nodecg-io
167159
* or if this is a old config from nodecg-io <= 0.2.
168160
*
169-
* If this is a new configuration a new salt will be generated and set inside the EncryptedData object.
161+
* If this is a new configuration a new salt will be generated, set inside the EncryptedData object and returned.
170162
* If this is a old configuration from nodecg-io <= 0.2 it will be migrated to the new format as well.
171163
*
172164
* @param data the encrypted data where the salt should be ensured to be available
173165
* @param password the password of the encrypted data. Used if this config needs to be migrated
166+
* @return returns the either retrieved or generated salt
174167
*/
175-
export function ensureEncryptionSaltIsSet(data: EncryptedData, password: string): void {
168+
export async function getEncryptionSalt(data: EncryptedData, password: string): Promise<string> {
176169
if (data.salt !== undefined) {
177170
// We already have a salt, so we have the new (nodecg-io >=0.3) format too.
178171
// We don't need to do anything then.
179-
return;
172+
return data.salt;
180173
}
181174

182175
// 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)
191184
// This means that this is a old config (nodecg-io <=0.2), that we need to migrate to the new format.
192185

193186
// Re-encrypt the configuration using our own derived key instead of the password.
194-
const newEncryptionKey = deriveEncryptionKey(password, salt);
187+
const newEncryptionKey = await deriveEncryptionKey(password, salt);
195188
const newEncryptionKeyArr = crypto.enc.Hex.parse(newEncryptionKey);
196189
const res = reEncryptData(data, password, newEncryptionKeyArr);
197190
if (res.failed) {
@@ -200,6 +193,7 @@ export function ensureEncryptionSaltIsSet(data: EncryptedData, password: string)
200193
}
201194

202195
data.salt = salt;
196+
return salt;
203197
}
204198

205199
/**
@@ -455,8 +449,8 @@ export class PersistenceManager {
455449
try {
456450
this.nodecg.log.info("Attempting to automatically login...");
457451

458-
ensureEncryptionSaltIsSet(this.encryptedData.value, password);
459-
const encryptionKey = deriveEncryptionKey(password, this.encryptedData.value.salt ?? "");
452+
const salt = await getEncryptionSalt(this.encryptedData.value, password);
453+
const encryptionKey = await deriveEncryptionKey(password, salt);
460454
const loadResult = await this.load(encryptionKey);
461455

462456
if (!loadResult.failed) {

nodecg-io-core/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
},
4545
"license": "MIT",
4646
"devDependencies": {
47+
"@types/argon2-browser": "^1.18.1",
4748
"@types/crypto-js": "^4.1.1",
4849
"@types/jest": "^28.1.6",
4950
"@types/node": "^18.0.3",
@@ -54,6 +55,7 @@
5455
},
5556
"dependencies": {
5657
"ajv": "^8.11.0",
58+
"argon2-browser": "^1.18.0",
5759
"crypto-js": "^4.1.1",
5860
"tslib": "^2.4.0"
5961
}

0 commit comments

Comments
 (0)