Skip to content

Commit 6fddb64

Browse files
committed
Removed encryptionKey and optimize internal property persistence
1 parent 8f763b6 commit 6fddb64

File tree

7 files changed

+101
-105
lines changed

7 files changed

+101
-105
lines changed

readme.md

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -278,18 +278,6 @@ Overrides `projectName`.
278278

279279
The only use-case I can think of is having the config located in the app directory or on some external storage.
280280

281-
#### encryptionKey
282-
283-
Type: `string | Uint8Array | TypedArray | DataView`\
284-
Default: `undefined`
285-
286-
> [!CAUTION]
287-
> This is **not intended for security purposes**, since the encryption key would be easily found inside a plain-text Node.js app.
288-
289-
Its main use is for obscurity. If a user looks through the config directory and finds the config file, since it's just a JSON file, they may be tempted to modify it. By providing an encryption key, the file will be obfuscated, which should hopefully deter any users from doing so.
290-
291-
When specified, the store will be encrypted using the [`aes-256-cbc`](https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation) encryption algorithm.
292-
293281
#### fileExtension
294282

295283
Type: `string`\

source/index.ts

Lines changed: 39 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,6 @@ import ajvFormatsModule from 'ajv-formats';
1818
import debounceFn from 'debounce-fn';
1919
import semver from 'semver';
2020
import {type JSONSchema} from 'json-schema-typed';
21-
import {
22-
concatUint8Arrays,
23-
stringToUint8Array,
24-
uint8ArrayToString,
25-
} from 'uint8array-extras';
2621
import {
2722
type Deserialize,
2823
type Migrations,
@@ -38,8 +33,6 @@ import {
3833
type Schema,
3934
} from './types.js';
4035

41-
const encryptionAlgorithm = 'aes-256-cbc';
42-
4336
const createPlainObject = <T = Record<string, unknown>>(): T => Object.create(null);
4437

4538
// Minimal wrapper: clone and assign to null-prototype object
@@ -69,7 +62,6 @@ export default class Conf<T extends Record<string, any> = Record<string, unknown
6962
readonly path: string;
7063
readonly events: EventTarget;
7164
#validator?: AjvValidateFunction;
72-
readonly #encryptionKey?: string | Uint8Array | NodeJS.TypedArray | DataView;
7365
readonly #options: Readonly<Partial<Options<T>>>;
7466
readonly #defaultValues: Partial<T> = createPlainObject();
7567
#isInMigration = false;
@@ -78,6 +70,7 @@ export default class Conf<T extends Record<string, any> = Record<string, unknown
7870
#debouncedChangeHandler?: () => void;
7971

8072
#cache?: T;
73+
#internalBackup?: Record<string, unknown>;
8174
#writePending = false;
8275
#writeTimer?: NodeJS.Timeout;
8376
#atomicChangeLock?: PromiseWithResolvers<void>;
@@ -89,7 +82,6 @@ export default class Conf<T extends Record<string, any> = Record<string, unknown
8982
this.#applyDefaultValues(options);
9083
this.#configureSerialization(options);
9184
this.events = new EventTarget();
92-
this.#encryptionKey = options.encryptionKey;
9385
this.path = this.#resolvePath(options);
9486
this.#initializeStore(options);
9587

@@ -107,7 +99,6 @@ export default class Conf<T extends Record<string, any> = Record<string, unknown
10799
return;
108100
}
109101

110-
this.#cache &&= undefined;
111102
this._read();
112103
}
113104

@@ -297,6 +288,10 @@ export default class Conf<T extends Record<string, any> = Record<string, unknown
297288
delete<Key extends keyof T>(key: Key): void;
298289
delete<Key extends DotNotationKeyOf<T>>(key: Key): void;
299290
delete(key: string): void {
291+
if (this._isReservedKeyPath(key)) {
292+
throw new Error(`The key \`${key}\` is reserved and cannot be deleted`);
293+
}
294+
300295
const {store} = this;
301296
if (this.#options.accessPropertiesByDotNotation) {
302297
deleteProperty(store, key);
@@ -395,25 +390,21 @@ export default class Conf<T extends Record<string, any> = Record<string, unknown
395390
}
396391

397392
set store(value: T) {
398-
this._ensureDirectory();
399-
400393
// Preserve existing internal data if it exists and the new value doesn't contain it
401-
if (!hasProperty(value, INTERNAL_KEY)) {
394+
if (hasProperty(value, INTERNAL_KEY)) {
395+
this.#internalBackup = getProperty(value, INTERNAL_KEY);
396+
} else if (this.#internalBackup) {
402397
try {
403398
// Read directly from file to avoid recursion during migration
404-
const data = fs.readFileSync(this.path, this.#encryptionKey ? null : 'utf8');
405-
const dataString = this._decryptData(data);
406-
const currentStore = this._deserialize(dataString);
407-
if (hasProperty(currentStore, INTERNAL_KEY)) {
408-
setProperty(value, INTERNAL_KEY, getProperty(currentStore, INTERNAL_KEY));
409-
}
399+
setProperty(value, INTERNAL_KEY, this.#internalBackup);
410400
} catch {
411401
// Silently ignore errors when trying to preserve internal data
412402
// This could happen if the file doesn't exist yet or is corrupted
413403
// In these cases, we just proceed without preserving internal data
414404
}
415405
}
416406

407+
// Validate before updating cache to ensure cache is never left in invalid state
417408
if (!this.#isInMigration) {
418409
this._validate(value);
419410
}
@@ -487,32 +478,24 @@ export default class Conf<T extends Record<string, any> = Record<string, unknown
487478
}
488479
}
489480

490-
private _decryptData(data: string | Uint8Array): string {
491-
if (!this.#encryptionKey) {
492-
return typeof data === 'string' ? data : uint8ArrayToString(data);
481+
private _decryptData(data: string | Buffer): string {
482+
const {encryption} = this.#options;
483+
484+
if (!encryption) {
485+
return data.toString();
493486
}
494487

495-
// Check if an initialization vector has been used to encrypt the data.
496-
try {
497-
const initializationVector = data.slice(0, 16);
498-
const password = crypto.pbkdf2Sync(this.#encryptionKey, initializationVector, 10_000, 32, 'sha512');
499-
const decipher = crypto.createDecipheriv(encryptionAlgorithm, password, initializationVector);
500-
const slice = data.slice(17);
501-
const dataUpdate = typeof slice === 'string' ? stringToUint8Array(slice) : slice;
502-
return uint8ArrayToString(concatUint8Arrays([decipher.update(dataUpdate), decipher.final()]));
503-
} catch {
504-
try {
505-
// Fallback to legacy scheme (iv.toString() as salt)
506-
const initializationVector = data.slice(0, 16);
507-
const password = crypto.pbkdf2Sync(this.#encryptionKey, initializationVector.toString(), 10_000, 32, 'sha512');
508-
const decipher = crypto.createDecipheriv(encryptionAlgorithm, password, initializationVector);
509-
const slice = data.slice(17);
510-
const dataUpdate = typeof slice === 'string' ? stringToUint8Array(slice) : slice;
511-
return uint8ArrayToString(concatUint8Arrays([decipher.update(dataUpdate), decipher.final()]));
512-
} catch {}
488+
return encryption.decrypt(typeof data === 'string' ? Buffer.from(data) : data);
489+
}
490+
491+
private _encryptData(data: string): Buffer {
492+
const {encryption} = this.#options;
493+
494+
if (!encryption) {
495+
return Buffer.from(data);
513496
}
514497

515-
return typeof data === 'string' ? data : uint8ArrayToString(data);
498+
return encryption.encrypt(data);
516499
}
517500

518501
private _handleStoreChange(callback: OnDidAnyChangeCallback<T>): Unsubscribe {
@@ -563,18 +546,15 @@ export default class Conf<T extends Record<string, any> = Record<string, unknown
563546
}
564547

565548
private readonly _deserialize: Deserialize<T> = value => {
566-
const {deserialize, encryption} = this.#options;
567-
const data = encryption ? encryption.decrypt(Uint8Array.from(value)) : value;
549+
const {deserialize} = this.#options;
568550

569-
return deserialize ? deserialize(data) : JSON.parse(data);
551+
return deserialize ? deserialize(value) : JSON.parse(value);
570552
};
571553

572554
private readonly _serialize: Serialize<T> = value => {
573-
const {serialize, encryption} = this.#options;
555+
const {serialize} = this.#options;
574556

575-
const data = serialize ? serialize(value) : JSON.stringify(value, undefined, '\t');
576-
577-
return encryption ? encryption.encrypt(data).toString() : data;
557+
return serialize ? serialize(value) : JSON.stringify(value, undefined, '\t');
578558
};
579559

580560
private _validate(data: T | unknown): void {
@@ -598,8 +578,10 @@ export default class Conf<T extends Record<string, any> = Record<string, unknown
598578
}
599579

600580
private _read(): void {
581+
this.#internalBackup = undefined;
582+
601583
try {
602-
const data = fs.readFileSync(this.path, this.#encryptionKey ? null : 'utf8');
584+
const data = fs.readFileSync(this.path);
603585
const dataString = this._decryptData(data);
604586
const deserializedData = this._deserialize(dataString);
605587

@@ -608,6 +590,8 @@ export default class Conf<T extends Record<string, any> = Record<string, unknown
608590
}
609591

610592
this.#cache = Object.assign(createPlainObject(), deserializedData);
593+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
594+
this.#internalBackup = this.#cache[INTERNAL_KEY] ?? undefined;
611595
} catch (error: unknown) {
612596
if ((error as any)?.code === 'ENOENT') {
613597
this._ensureDirectory();
@@ -642,22 +626,18 @@ export default class Conf<T extends Record<string, any> = Record<string, unknown
642626
if (this.#writeTimer) {
643627
this.#writePending = true;
644628
} else {
645-
this._forceWrite();
629+
this.writeToDisk();
646630
this._startWriteTimeout();
647631
}
648632
}
649633

650-
private _forceWrite(): void {
634+
writeToDisk(): void {
651635
this._cancelWriteTimeout();
652636

653-
let data: string | Uint8Array = this._serialize(this.#cache!);
637+
// Validation already done in _write(), no need to validate again here
638+
const data: string | Uint8Array = this._encryptData(this._serialize(this.#cache ?? createPlainObject<T>()));
654639

655-
if (this.#encryptionKey) {
656-
const initializationVector = crypto.randomBytes(16);
657-
const password = crypto.pbkdf2Sync(this.#encryptionKey, initializationVector, 10_000, 32, 'sha512');
658-
const cipher = crypto.createCipheriv(encryptionAlgorithm, password, initializationVector);
659-
data = concatUint8Arrays([initializationVector, stringToUint8Array(':'), cipher.update(stringToUint8Array(data)), cipher.final()]);
660-
}
640+
this._ensureDirectory();
661641

662642
// Temporary workaround for Conf being packaged in a Ubuntu Snap app.
663643
// See https://github.com/sindresorhus/conf/pull/82
@@ -689,7 +669,7 @@ export default class Conf<T extends Record<string, any> = Record<string, unknown
689669

690670
if (this.#writePending) {
691671
this.#writePending = false;
692-
this._forceWrite();
672+
this.writeToDisk();
693673
}
694674
}, this.#options.writeTimeout);
695675
}
@@ -775,11 +755,6 @@ export default class Conf<T extends Record<string, any> = Record<string, unknown
775755
} catch (error: unknown) {
776756
// Restore backup (validation is skipped during migration)
777757
this.store = storeBackup;
778-
// Try to write the restored state to disk to ensure rollback persists
779-
// If write fails (e.g., read-only file), we still throw the original error
780-
try {
781-
this._write(storeBackup);
782-
} catch {}
783758

784759
const errorMessage = error instanceof Error ? error.message : String(error);
785760
throw new Error(`Something went wrong during the migration! Changes applied to the store until this failed migration will be restored. ${errorMessage}`);

source/types.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -167,20 +167,9 @@ export type Options<T extends Record<string, unknown>> = {
167167
*/
168168
cwd?: string;
169169

170-
/**
171-
Note that this is __not intended for security purposes__, since the encryption key would be easily found inside a plain-text Node.js app.
172-
173-
Its main use is for obscurity. If a user looks through the config directory and finds the config file, since it's just a JSON file, they may be tempted to modify it. By providing an encryption key, the file will be obfuscated, which should hopefully deter any users from doing so.
174-
175-
It also has the added bonus of ensuring the config file's integrity. If the file is changed in any way, the decryption will not work, in which case the store will just reset back to its default state.
176-
177-
When specified, the store will be encrypted using the [`aes-256-cbc`](https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation) encryption algorithm.
178-
*/
179-
encryptionKey?: string | Uint8Array | NodeJS.TypedArray | DataView;
180-
181170
encryption?: {
182-
encrypt: (data: string) => Uint8Array;
183-
decrypt: (data: Uint8Array) => string;
171+
encrypt: (data: string) => Buffer;
172+
decrypt: (data: Buffer) => string;
184173
};
185174

186175
/**

test/advanced-features.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,8 +208,8 @@ describe('Advanced Features', () => {
208208
}
209209

210210
const cwd = createTempDirectory();
211-
const conf = trackConf(new Conf({cwd, watch: true, encryptionKey: 'secret-key'}));
212-
const writer = trackConf(new Conf({cwd, encryptionKey: 'secret-key'}));
211+
const conf = trackConf(new Conf({cwd, watch: true}));
212+
const writer = trackConf(new Conf({cwd}));
213213
writer.set('foo', 'bar');
214214

215215
const changePromise = pEvent(conf.events, 'change', {timeout: 3000});

test/index.test-d.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
/* eslint-disable no-new, @typescript-eslint/naming-convention */
2-
import {stringToUint8Array} from 'uint8array-extras';
32
import {expectTypeOf} from 'expect-type';
43
import Conf from '../source/index.js';
54

@@ -37,19 +36,15 @@ new Conf<UnicornFoo>({
3736
});
3837
new Conf<UnicornFoo>({
3938
projectName: typeTestProjectName,
40-
encryptionKey: '',
4139
});
4240
new Conf<UnicornFoo>({
4341
projectName: typeTestProjectName,
44-
encryptionKey: stringToUint8Array(''),
4542
});
4643
new Conf<UnicornFoo>({
4744
projectName: typeTestProjectName,
48-
encryptionKey: new Uint8Array([1]),
4945
});
5046
new Conf<UnicornFoo>({
5147
projectName: typeTestProjectName,
52-
encryptionKey: new DataView(new ArrayBuffer(2)),
5348
});
5449
new Conf<UnicornFoo>({
5550
projectName: typeTestProjectName,

0 commit comments

Comments
 (0)