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 +54 -5
- package/dist/crypto/decrypt.js +37 -7
- package/dist/crypto/encrypt.js +25 -11
- package/dist/crypto/utils.js +56 -16
- package/dist/utils/config.js +8 -5
- package/package.json +2 -1
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
|
-
-
|
|
80
|
-
-
|
|
81
|
-
-
|
|
82
|
-
-
|
|
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
|
package/dist/crypto/decrypt.js
CHANGED
|
@@ -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
|
|
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,
|
|
36
|
-
|
|
37
|
-
|
|
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
|
}
|
package/dist/crypto/encrypt.js
CHANGED
|
@@ -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
|
-
|
|
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} [
|
|
24
|
+
* @param {string|object} [algorithmOptions=DEFAULT_ALGORITHM]
|
|
19
25
|
* @returns {string}
|
|
20
26
|
*/
|
|
21
|
-
export function encryptValue(value, key, fieldPath,
|
|
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
|
|
27
|
-
const derivedKey = deriveKey(key,
|
|
28
|
-
const iv = generateIv(
|
|
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
|
|
32
|
-
const
|
|
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:
|
|
49
|
+
algorithm: resolvedOptions.algorithm,
|
|
36
50
|
salt,
|
|
37
51
|
iv,
|
|
38
52
|
data: encrypted
|
package/dist/crypto/utils.js
CHANGED
|
@@ -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}
|
|
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
|
|
48
|
-
|
|
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 >=
|
|
54
|
-
return hash.subarray(0,
|
|
93
|
+
if (hash.length >= requiredLength) {
|
|
94
|
+
return hash.subarray(0, requiredLength);
|
|
55
95
|
}
|
|
56
96
|
|
|
57
|
-
const result = Buffer.allocUnsafe(
|
|
97
|
+
const result = Buffer.allocUnsafe(requiredLength);
|
|
58
98
|
let offset = 0;
|
|
59
99
|
let material = hash;
|
|
60
|
-
while (offset <
|
|
61
|
-
const chunk = material.subarray(0, Math.min(material.length,
|
|
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
|
|
72
|
-
* @param {string}
|
|
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
|
|
78
|
-
if (
|
|
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(
|
|
122
|
+
return randomBytes(length);
|
|
83
123
|
}
|
|
84
124
|
|
|
85
125
|
/**
|
package/dist/utils/config.js
CHANGED
|
@@ -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,
|
|
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