yamlock 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,6 +66,49 @@ const unlocked = processConfig(locked, { mode: 'decrypt', key: process.env.YAMLO
66
66
 
67
67
  See `examples/basic.js` for a runnable end-to-end script (`node examples/basic.js`).
68
68
 
69
+ ### Algorithm customization
70
+
71
+ Each function accepts either a cipher name or an options object:
72
+
73
+ ```js
74
+ const encrypted = encryptValue('swordfish', KEY, 'db.password', {
75
+ algorithm: 'chacha20-poly1305',
76
+ ivLength: 12 // override the IV size used during encryption
77
+ });
78
+
79
+ // When decrypting, the algorithm is inferred from the payload,
80
+ // but you can still override key/IV sizes if the cipher requires it.
81
+ const decrypted = decryptValue(encrypted, KEY, 'db.password', /* optional overrides */);
82
+
83
+ // processConfig propagates the same options through every nested field.
84
+ const processed = processConfig(
85
+ { db: { password: 'swordfish' }, api: { token: 'secret' } },
86
+ {
87
+ mode: 'encrypt',
88
+ key: KEY,
89
+ algorithm: { algorithm: 'aes-192-cbc', ivLength: 24 }
90
+ }
91
+ );
92
+
93
+ // Later you can decrypt with the same options:
94
+ const restored = processConfig(processed, {
95
+ mode: 'decrypt',
96
+ key: KEY,
97
+ algorithm: { algorithm: 'aes-192-cbc', ivLength: 24 }
98
+ });
99
+ ```
100
+
101
+ ### Supported algorithms
102
+
103
+ | Algorithm | Type | Notes |
104
+ |-----------|------|-------|
105
+ | `aes-128-cbc` | Block cipher (CBC) | 128-bit keys, 16-byte IV. Works well for backward-compatibility scenarios. |
106
+ | `aes-192-cbc` | Block cipher (CBC) | 192-bit keys, 16-byte IV. Slightly stronger than AES-128 with the same IV requirements. |
107
+ | `aes-256-cbc` (default) | Block cipher (CBC) | 256-bit keys, 16-byte IV. Balanced combination of strength and compatibility. |
108
+ | `chacha20-poly1305` | AEAD stream cipher | 256-bit keys, 12-byte nonce, 16-byte auth tag. Provides built-in integrity/authentication. |
109
+
110
+ You can also pass any algorithm supported by the current Node.js runtime (`crypto.getCiphers()`), along with custom `keyLength`, `ivLength`, or `authTagLength` overrides. Only the algorithms above are actively tested; additional presets may be added or revised in future releases.
111
+
69
112
  ### Encrypted value format
70
113
 
71
114
  Every locked string follows the format:
@@ -75,11 +118,11 @@ yl|<algorithm>|<salt_base64>|<iv_base64>|<data_base64>
75
118
  ```
76
119
 
77
120
  Where:
78
- - yl - format marker prefix
79
- - <algorithm> - algorithm name (e.g., aes-256-cbc)
80
- - <salt_base64> - Base64-encoded field path
81
- - <iv_base64> - Base64-encoded initialization vector
82
- - <data_base64> - Base64-encoded encrypted data
121
+ - `yl` - format marker prefix
122
+ - `<algorithm>` - algorithm name (e.g., aes-256-cbc)
123
+ - `<salt_base64>` - Base64-encoded field path
124
+ - `<iv_base64>` - Base64-encoded initialization vector
125
+ - `<data_base64>` - Base64-encoded encrypted data
83
126
 
84
127
  The salt is derived from the full field path. Moving or renaming the field invalidates the salt, preventing accidental decryption in the wrong location.
85
128
 
@@ -106,6 +149,12 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow, available scrip
106
149
 
107
150
  `yamlock` returns `0` when encryption/decryption completes successfully and `1` on validation or runtime errors (missing keys, malformed payloads, failed file reads). Use these exit codes to gate CI jobs or deployment steps.
108
151
 
152
+ ## Future work
153
+
154
+ - Additional cipher presets and stronger default algorithms.
155
+ - More CLI/API examples for rotating keys, selective field targeting, and CI automation.
156
+ - Configurable behavior for non-string values (skip vs. coerce) and stricter file format validation.
157
+
109
158
  ## License
110
159
 
111
160
  MIT © PAVEL TKACHEV
@@ -3,25 +3,37 @@ import { createDecipheriv } from 'node:crypto';
3
3
  import {
4
4
  decodeFieldPathSalt,
5
5
  deriveKey,
6
- ensureAlgorithm,
7
6
  isYamlockPayload,
8
- parsePayload
7
+ parsePayload,
8
+ resolveAlgorithmOptions
9
9
  } from './utils.js';
10
10
 
11
+ function resolveDecryptOptions(payloadAlgorithm, overrides) {
12
+ if (typeof overrides === 'string' || overrides === undefined) {
13
+ return resolveAlgorithmOptions({ algorithm: payloadAlgorithm });
14
+ }
15
+
16
+ return resolveAlgorithmOptions({
17
+ ...overrides,
18
+ algorithm: payloadAlgorithm
19
+ });
20
+ }
21
+
11
22
  /**
12
23
  * Decrypts a yamlock payload for the provided field path.
13
24
  * @param {string} encryptedValue
14
25
  * @param {string|Buffer} key
15
26
  * @param {string} fieldPath
27
+ * @param {string|object} [algorithmOptions]
16
28
  * @returns {string}
17
29
  */
18
- export function decryptValue(encryptedValue, key, fieldPath) {
30
+ export function decryptValue(encryptedValue, key, fieldPath, algorithmOptions) {
19
31
  if (!isYamlockPayload(encryptedValue)) {
20
32
  throw new Error('decryptValue expects a yamlock-formatted payload.');
21
33
  }
22
34
 
23
35
  const payload = parsePayload(encryptedValue);
24
- const normalizedAlgorithm = ensureAlgorithm(payload.algorithm);
36
+ const resolvedOptions = resolveDecryptOptions(payload.algorithm, algorithmOptions);
25
37
  const saltFieldPath = decodeFieldPathSalt(payload.salt);
26
38
 
27
39
  if (!fieldPath) {
@@ -32,9 +44,27 @@ export function decryptValue(encryptedValue, key, fieldPath) {
32
44
  throw new Error('Field path does not match the encrypted payload.');
33
45
  }
34
46
 
35
- const derivedKey = deriveKey(key, normalizedAlgorithm);
36
- const decipher = createDecipheriv(normalizedAlgorithm, derivedKey, payload.iv);
37
- const decrypted = Buffer.concat([decipher.update(payload.data), decipher.final()]);
47
+ const derivedKey = deriveKey(key, resolvedOptions);
48
+ let ciphertext = payload.data;
49
+ let authTag;
50
+ if (resolvedOptions.authTagLength) {
51
+ if (ciphertext.length < resolvedOptions.authTagLength) {
52
+ throw new Error('Encrypted payload is missing an authentication tag.');
53
+ }
54
+ authTag = ciphertext.subarray(ciphertext.length - resolvedOptions.authTagLength);
55
+ ciphertext = ciphertext.subarray(0, ciphertext.length - resolvedOptions.authTagLength);
56
+ }
57
+
58
+ const decipherOptions = resolvedOptions.authTagLength ? { authTagLength: resolvedOptions.authTagLength } : undefined;
59
+ const decipher = createDecipheriv(resolvedOptions.algorithm, derivedKey, payload.iv, decipherOptions);
60
+ if (authTag) {
61
+ if (typeof decipher.setAuthTag !== 'function') {
62
+ throw new Error(`Algorithm ${resolvedOptions.algorithm} requires auth tags but setAuthTag is unavailable.`);
63
+ }
64
+ decipher.setAuthTag(authTag);
65
+ }
66
+
67
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
38
68
 
39
69
  return decrypted.toString('utf8');
40
70
  }
@@ -1,38 +1,52 @@
1
1
  import { createCipheriv } from 'node:crypto';
2
2
 
3
3
  import {
4
+ DEFAULT_ALGORITHM,
4
5
  deriveKey,
5
6
  encodeFieldPathSalt,
6
- ensureAlgorithm,
7
7
  formatPayload,
8
- generateIv
8
+ generateIv,
9
+ resolveAlgorithmOptions
9
10
  } from './utils.js';
10
11
 
11
- const DEFAULT_ALGORITHM = 'aes-256-cbc';
12
+ function resolveOptions(input) {
13
+ if (typeof input === 'string' || input === undefined) {
14
+ return resolveAlgorithmOptions({ algorithm: input ?? DEFAULT_ALGORITHM });
15
+ }
16
+ return resolveAlgorithmOptions(input);
17
+ }
12
18
 
13
19
  /**
14
20
  * Encrypts a string value for a specific configuration field path.
15
21
  * @param {string} value
16
22
  * @param {string|Buffer} key
17
23
  * @param {string} fieldPath
18
- * @param {string} [algorithm=DEFAULT_ALGORITHM]
24
+ * @param {string|object} [algorithmOptions=DEFAULT_ALGORITHM]
19
25
  * @returns {string}
20
26
  */
21
- export function encryptValue(value, key, fieldPath, algorithm = DEFAULT_ALGORITHM) {
27
+ export function encryptValue(value, key, fieldPath, algorithmOptions = DEFAULT_ALGORITHM) {
22
28
  if (typeof value !== 'string') {
23
29
  throw new Error('encryptValue expects the value to be a string.');
24
30
  }
25
31
 
26
- const normalizedAlgorithm = ensureAlgorithm(algorithm);
27
- const derivedKey = deriveKey(key, normalizedAlgorithm);
28
- const iv = generateIv(normalizedAlgorithm);
32
+ const resolvedOptions = resolveOptions(algorithmOptions);
33
+ const derivedKey = deriveKey(key, resolvedOptions);
34
+ const iv = generateIv(resolvedOptions);
29
35
  const salt = encodeFieldPathSalt(fieldPath);
30
36
 
31
- const cipher = createCipheriv(normalizedAlgorithm, derivedKey, iv);
32
- const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
37
+ const cipherOptions = resolvedOptions.authTagLength ? { authTagLength: resolvedOptions.authTagLength } : undefined;
38
+ const cipher = createCipheriv(resolvedOptions.algorithm, derivedKey, iv, cipherOptions);
39
+ let encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
40
+ if (resolvedOptions.authTagLength) {
41
+ if (typeof cipher.getAuthTag !== 'function') {
42
+ throw new Error(`Algorithm ${resolvedOptions.algorithm} requires auth tags but getAuthTag is unavailable.`);
43
+ }
44
+ const authTag = cipher.getAuthTag();
45
+ encrypted = Buffer.concat([encrypted, authTag]);
46
+ }
33
47
 
34
48
  return formatPayload({
35
- algorithm: normalizedAlgorithm,
49
+ algorithm: resolvedOptions.algorithm,
36
50
  salt,
37
51
  iv,
38
52
  data: encrypted
@@ -2,6 +2,13 @@ import { createHash, getCipherInfo, getCiphers, randomBytes } from 'node:crypto'
2
2
 
3
3
  export const YAMLOCK_PREFIX = 'yl';
4
4
  export const YAMLOCK_DELIMITER = '|';
5
+ export const DEFAULT_ALGORITHM = 'aes-256-cbc';
6
+ export const ALGORITHM_PRESETS = {
7
+ 'aes-256-cbc': { keyLength: 32, ivLength: 16, authTagLength: 0 },
8
+ 'aes-192-cbc': { keyLength: 24, ivLength: 16, authTagLength: 0 },
9
+ 'aes-128-cbc': { keyLength: 16, ivLength: 16, authTagLength: 0 },
10
+ 'chacha20-poly1305': { keyLength: 32, ivLength: 12, authTagLength: 16 }
11
+ };
5
12
 
6
13
  /**
7
14
  * Returns the sorted list of cipher algorithms supported by the current runtime.
@@ -28,14 +35,46 @@ export function ensureAlgorithm(algorithm) {
28
35
  return algorithm;
29
36
  }
30
37
 
38
+ function normalizeAlgorithmInput(input) {
39
+ if (!input || typeof input === 'string') {
40
+ return { algorithm: input };
41
+ }
42
+ return input;
43
+ }
44
+
45
+ /**
46
+ * Resolves algorithm settings with optional key/IV overrides.
47
+ * @param {object|string} [input]
48
+ * @param {string} [input.algorithm]
49
+ * @param {number} [input.keyLength]
50
+ * @param {number} [input.ivLength]
51
+ * @returns {{ algorithm: string, keyLength: number, ivLength: number, authTagLength: number }}
52
+ */
53
+ export function resolveAlgorithmOptions(input) {
54
+ const normalized = normalizeAlgorithmInput(input);
55
+ const algorithmName = ensureAlgorithm(normalized.algorithm ?? DEFAULT_ALGORITHM);
56
+ const preset = ALGORITHM_PRESETS[algorithmName] ?? {};
57
+ const cipherInfo = getCipherInfo(algorithmName) ?? {};
58
+
59
+ const keyLength = normalized.keyLength ?? preset.keyLength ?? cipherInfo.keyLength ?? 32;
60
+ const ivLength = normalized.ivLength ?? preset.ivLength ?? cipherInfo.ivLength ?? 16;
61
+ const authTagLength = normalized.authTagLength ?? preset.authTagLength ?? 0;
62
+
63
+ return {
64
+ algorithm: algorithmName,
65
+ keyLength,
66
+ ivLength,
67
+ authTagLength
68
+ };
69
+ }
70
+
31
71
  /**
32
72
  * Derives a key buffer with the exact size required by the cipher.
33
73
  * @param {string|Buffer} secret
34
- * @param {string} algorithm
74
+ * @param {{ algorithm: string, keyLength: number }} options
35
75
  * @returns {Buffer}
36
76
  */
37
- export function deriveKey(secret, algorithm) {
38
- const normalizedAlgorithm = ensureAlgorithm(algorithm);
77
+ export function deriveKey(secret, { algorithm, keyLength }) {
39
78
  if (secret === undefined || secret === null || secret === '') {
40
79
  throw new Error('Encryption key is required to derive a cipher key.');
41
80
  }
@@ -44,21 +83,22 @@ export function deriveKey(secret, algorithm) {
44
83
  ? secret
45
84
  : Buffer.from(String(secret), 'utf8');
46
85
 
47
- const keyLength = getCipherInfo(normalizedAlgorithm)?.keyLength ?? 32;
48
- if (baseBuffer.length === keyLength) {
86
+ const normalizedAlgorithm = ensureAlgorithm(algorithm);
87
+ const requiredLength = keyLength ?? getCipherInfo(normalizedAlgorithm)?.keyLength ?? 32;
88
+ if (baseBuffer.length === requiredLength) {
49
89
  return baseBuffer;
50
90
  }
51
91
 
52
92
  const hash = createHash('sha512').update(baseBuffer).digest();
53
- if (hash.length >= keyLength) {
54
- return hash.subarray(0, keyLength);
93
+ if (hash.length >= requiredLength) {
94
+ return hash.subarray(0, requiredLength);
55
95
  }
56
96
 
57
- const result = Buffer.allocUnsafe(keyLength);
97
+ const result = Buffer.allocUnsafe(requiredLength);
58
98
  let offset = 0;
59
99
  let material = hash;
60
- while (offset < keyLength) {
61
- const chunk = material.subarray(0, Math.min(material.length, keyLength - offset));
100
+ while (offset < requiredLength) {
101
+ const chunk = material.subarray(0, Math.min(material.length, requiredLength - offset));
62
102
  chunk.copy(result, offset);
63
103
  offset += chunk.length;
64
104
  material = createHash('sha512').update(material).digest();
@@ -68,18 +108,18 @@ export function deriveKey(secret, algorithm) {
68
108
  }
69
109
 
70
110
  /**
71
- * Generates an IV buffer for the given algorithm (defaults to 16 bytes).
72
- * @param {string} algorithm
111
+ * Generates an IV buffer for the given algorithm configuration.
112
+ * @param {{ algorithm: string, ivLength: number }} options
73
113
  * @returns {Buffer}
74
114
  */
75
- export function generateIv(algorithm) {
115
+ export function generateIv({ algorithm, ivLength }) {
76
116
  const normalizedAlgorithm = ensureAlgorithm(algorithm);
77
- const ivLength = getCipherInfo(normalizedAlgorithm)?.ivLength ?? 16;
78
- if (ivLength === 0) {
117
+ const length = ivLength ?? getCipherInfo(normalizedAlgorithm)?.ivLength ?? 16;
118
+ if (length === 0) {
79
119
  return Buffer.alloc(0);
80
120
  }
81
121
 
82
- return randomBytes(ivLength);
122
+ return randomBytes(length);
83
123
  }
84
124
 
85
125
  /**
@@ -13,11 +13,12 @@ const MODES = {
13
13
  * @param {Object} options
14
14
  * @param {'encrypt'|'decrypt'} options.mode
15
15
  * @param {string|Buffer} options.key
16
- * @param {string} [options.algorithm]
16
+ * @param {string|object} [options.algorithm]
17
+ * @param {object} [options.algorithmOptions]
17
18
  * @param {Array<string|number>} [options.parentPath]
18
19
  * @returns {Object|Array}
19
20
  */
20
- export function processConfig(node, { mode, key, algorithm, parentPath = [] }) {
21
+ export function processConfig(node, { mode, key, algorithm, algorithmOptions, parentPath = [] }) {
21
22
  if (typeof node !== 'object' || node === null) {
22
23
  throw new Error('processConfig expects a non-null object or array.');
23
24
  }
@@ -28,6 +29,7 @@ export function processConfig(node, { mode, key, algorithm, parentPath = [] }) {
28
29
 
29
30
  const isArrayNode = Array.isArray(node);
30
31
  const result = isArrayNode ? [] : {};
32
+ const cryptoOptions = algorithmOptions ?? algorithm;
31
33
 
32
34
  Object.entries(node).forEach(([rawKey, value]) => {
33
35
  const segment = isArrayNode ? Number(rawKey) : rawKey;
@@ -38,7 +40,8 @@ export function processConfig(node, { mode, key, algorithm, parentPath = [] }) {
38
40
  result[targetKey] = processConfig(value, {
39
41
  mode,
40
42
  key,
41
- algorithm,
43
+ algorithm: cryptoOptions,
44
+ algorithmOptions: cryptoOptions,
42
45
  parentPath: [...parentPath, segment]
43
46
  });
44
47
  return;
@@ -50,9 +53,9 @@ export function processConfig(node, { mode, key, algorithm, parentPath = [] }) {
50
53
  }
51
54
 
52
55
  if (mode === MODES.ENCRYPT) {
53
- result[targetKey] = encryptValue(value, key, currentPath, algorithm);
56
+ result[targetKey] = encryptValue(value, key, currentPath, cryptoOptions);
54
57
  } else {
55
- result[targetKey] = decryptValue(value, key, currentPath);
58
+ result[targetKey] = decryptValue(value, key, currentPath, cryptoOptions);
56
59
  }
57
60
  });
58
61
 
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "yamlock",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
+ "author": "PAVEL TKACHEV",
4
5
  "description": "Value-level encryption for YAML/JSON configuration files with CLI + Node.js APIs.",
5
6
  "license": "MIT",
6
7
  "type": "module",