yamlock 0.1.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/LICENSE +21 -0
- package/README.md +115 -0
- package/bin/yamlock +6 -0
- package/dist/cli/.gitkeep +0 -0
- package/dist/cli/cli.js +139 -0
- package/dist/crypto/.gitkeep +0 -0
- package/dist/crypto/decrypt.js +40 -0
- package/dist/crypto/encrypt.js +40 -0
- package/dist/crypto/utils.js +172 -0
- package/dist/index.js +4 -0
- package/dist/utils/.gitkeep +0 -0
- package/dist/utils/config.js +60 -0
- package/dist/utils/path.js +39 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 PAVEL TKACHEV
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
```ascii
|
|
2
|
+
░█░█░█▀█░█▄░▄█░█░░░█▀█░█▀▀░█░█░
|
|
3
|
+
░░█░░█▀█░█░▀░█░█░░░█░█░█░░░█▀▄░
|
|
4
|
+
░░▀░░▀░▀░▀░░░▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
# yamlock
|
|
8
|
+
|
|
9
|
+
Value-level encryption for YAML and JSON configuration files. The name **yamlock** combines "YAML" and "lock" while also sounding like "warlock", hinting at a little configuration magic.
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
- Node.js 22.x via `asdf`
|
|
14
|
+
- Yarn Classic (1.x)
|
|
15
|
+
|
|
16
|
+
## Current status
|
|
17
|
+
|
|
18
|
+
- Package metadata, linting config, and MIT license are in place.
|
|
19
|
+
- Core crypto helpers (`encryptValue`, `decryptValue`, and supporting utils) work with per-field salts and have unit tests.
|
|
20
|
+
- `processConfig` can walk nested objects/arrays and apply encryption/decryption to every string value.
|
|
21
|
+
- Public API exports (`encryptValue`, `decryptValue`, `processConfig`, `getSupportedAlgorithms`) are wired and verified by tests.
|
|
22
|
+
- Directory structure for source, CLI, tests, and examples exists.
|
|
23
|
+
- CLI binary can encrypt/decrypt YAML and JSON files by calling `processConfig`.
|
|
24
|
+
|
|
25
|
+
## Working locally
|
|
26
|
+
|
|
27
|
+
1. Install the toolchain: `asdf install nodejs 22` and `yarn set version classic` if needed.
|
|
28
|
+
2. Install dependencies with `yarn install`.
|
|
29
|
+
3. Use the scripts below during development.
|
|
30
|
+
|
|
31
|
+
## Available scripts
|
|
32
|
+
|
|
33
|
+
- `yarn build` — copies the current `src` tree into `dist` (temporary until a real build pipeline appears).
|
|
34
|
+
- `yarn prepare` — invokes `yarn build` automatically when installing from git.
|
|
35
|
+
- `yarn test` — runs Node built-in test runner.
|
|
36
|
+
- `yarn lint` — executes ESLint with the provided config.
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
### CLI
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Encrypt values in a YAML file
|
|
44
|
+
YAMLOCK_KEY="super-secret" yamlock encrypt config.yaml
|
|
45
|
+
|
|
46
|
+
# Decrypt values in place using explicit key/algorithm flags
|
|
47
|
+
yamlock decrypt settings.json --key "super-secret" --algorithm aes-256-cbc
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The CLI detects YAML (`.yaml`/`.yml`) and JSON extensions automatically and writes the file back in the same format.
|
|
51
|
+
|
|
52
|
+
### Node.js API
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
import { encryptValue, decryptValue, processConfig } from 'yamlock';
|
|
56
|
+
|
|
57
|
+
const encrypted = encryptValue('swordfish', process.env.YAMLOCK_KEY, 'db.password');
|
|
58
|
+
const decrypted = decryptValue(encrypted, process.env.YAMLOCK_KEY, 'db.password');
|
|
59
|
+
|
|
60
|
+
const config = { db: { password: 'swordfish' } };
|
|
61
|
+
const locked = processConfig(config, { mode: 'encrypt', key: process.env.YAMLOCK_KEY });
|
|
62
|
+
const unlocked = processConfig(locked, { mode: 'decrypt', key: process.env.YAMLOCK_KEY });
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
See `examples/basic.js` for a runnable end-to-end script (`node examples/basic.js`).
|
|
66
|
+
|
|
67
|
+
### Encrypted value format
|
|
68
|
+
|
|
69
|
+
Every locked string follows the format:
|
|
70
|
+
|
|
71
|
+
```txt
|
|
72
|
+
yl|<algorithm>|<salt_base64>|<iv_base64>|<data_base64>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
where the salt is derived from the full field path. Moving or renaming the field invalidates the salt, preventing accidental decryption in the wrong location.
|
|
76
|
+
|
|
77
|
+
### Key rotation
|
|
78
|
+
|
|
79
|
+
See [docs/key-rotation.md](docs/key-rotation.md) for a step-by-step guide to rotating `YAMLOCK_KEY` without losing data.
|
|
80
|
+
|
|
81
|
+
## Project structure
|
|
82
|
+
|
|
83
|
+
```txt
|
|
84
|
+
yamlock/
|
|
85
|
+
├── src/ # Source files (API, CLI, utilities)
|
|
86
|
+
├── dist/ # Build output created by `yarn build`
|
|
87
|
+
├── bin/ # CLI entry point (loads dist/cli/cli.js)
|
|
88
|
+
├── test/ # Unit and integration suites
|
|
89
|
+
├── examples/ # Usage demos (TBD)
|
|
90
|
+
├── CHANGELOG.md # Step-by-step release history
|
|
91
|
+
└── README.md / LICENSE
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Inspiration and motivation
|
|
95
|
+
|
|
96
|
+
I have worked with Ruby on Rails apps for more than ten years and appreciated how its secret management evolved between 4.2 and 6.x. That flow influenced **yamlock**, but I also explored modern tools such as:
|
|
97
|
+
|
|
98
|
+
- [autoapply/yaml-crypt](https://github.com/autoapply/yaml-crypt)
|
|
99
|
+
- [huwtl/secure_yaml](https://github.com/huwtl/secure_yaml)
|
|
100
|
+
- [bitnami-labs/sealed-secrets](https://github.com/bitnami-labs/sealed-secrets)
|
|
101
|
+
- [getsops/sops](https://github.com/getsops/sops)
|
|
102
|
+
|
|
103
|
+
Each of those projects solves secure config storage differently, yet none fit my exact needs. **yamlock** is the bicycle I am building for my own projects to add an extra layer of encryption for sensitive YAML/JSON values while keeping the workflow lightweight.
|
|
104
|
+
|
|
105
|
+
## Contributing
|
|
106
|
+
|
|
107
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow, coding standards, and release instructions.
|
|
108
|
+
|
|
109
|
+
## Exit codes
|
|
110
|
+
|
|
111
|
+
`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.
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT © PAVEL TKACHEV
|
package/bin/yamlock
ADDED
|
File without changes
|
package/dist/cli/cli.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { resolve, extname } from 'node:path';
|
|
4
|
+
import { exit } from 'node:process';
|
|
5
|
+
|
|
6
|
+
import yaml from 'js-yaml';
|
|
7
|
+
|
|
8
|
+
import { processConfig } from '../utils/config.js';
|
|
9
|
+
|
|
10
|
+
const HELP_TEXT = `
|
|
11
|
+
░█░█░█▀█░█▄░▄█░█░░░█▀█░█▀▀░█░█░
|
|
12
|
+
░░█░░█▀█░█░▀░█░█░░░█░█░█░░░█▀▄░
|
|
13
|
+
░░▀░░▀░▀░▀░░░▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
yamlock <command> [options]
|
|
17
|
+
|
|
18
|
+
Commands:
|
|
19
|
+
encrypt <file> Encrypt string values in the given YAML/JSON file.
|
|
20
|
+
decrypt <file> Decrypt string values in the given YAML/JSON file.
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
-k, --key <value> Encryption key (or use YAMLOCK_KEY env).
|
|
24
|
+
-a, --algorithm <value> Cipher algorithm (default: aes-256-cbc).
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
function print(message) {
|
|
28
|
+
console.log(message);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function printError(message) {
|
|
32
|
+
console.error(message);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function detectFormat(filePath) {
|
|
36
|
+
const extension = extname(filePath).toLowerCase();
|
|
37
|
+
if (extension === '.yaml' || extension === '.yml') {
|
|
38
|
+
return 'yaml';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return 'json';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readConfigFile(filePath) {
|
|
45
|
+
const content = readFileSync(filePath, 'utf8');
|
|
46
|
+
const format = detectFormat(filePath);
|
|
47
|
+
|
|
48
|
+
if (format === 'yaml') {
|
|
49
|
+
return { format, data: yaml.load(content) ?? {} };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { format, data: JSON.parse(content) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeConfigFile(filePath, format, data) {
|
|
56
|
+
if (format === 'yaml') {
|
|
57
|
+
const serialized = yaml.dump(data, { lineWidth: 120 });
|
|
58
|
+
writeFileSync(filePath, serialized, 'utf8');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const serialized = JSON.stringify(data, null, 2);
|
|
63
|
+
writeFileSync(filePath, `${serialized}\n`, 'utf8');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseArgs(argv) {
|
|
67
|
+
const args = argv.slice(2);
|
|
68
|
+
const result = {
|
|
69
|
+
command: args[0],
|
|
70
|
+
file: args[1],
|
|
71
|
+
options: {}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
for (let i = 2; i < args.length; i += 1) {
|
|
75
|
+
const arg = args[i];
|
|
76
|
+
const next = args[i + 1];
|
|
77
|
+
|
|
78
|
+
if (arg === '-k' || arg === '--key') {
|
|
79
|
+
result.options.key = next;
|
|
80
|
+
i += 1;
|
|
81
|
+
} else if (arg === '-a' || arg === '--algorithm') {
|
|
82
|
+
result.options.algorithm = next;
|
|
83
|
+
i += 1;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function main() {
|
|
91
|
+
const { command, file, options } = parseArgs(process.argv);
|
|
92
|
+
|
|
93
|
+
if (!command || !file) {
|
|
94
|
+
print(HELP_TEXT.trim());
|
|
95
|
+
return exit(1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const key = options.key ?? process.env.YAMLOCK_KEY;
|
|
99
|
+
if (!key) {
|
|
100
|
+
printError('Encryption key is required via --key or YAMLOCK_KEY.');
|
|
101
|
+
return exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const absolutePath = resolve(process.cwd(), file);
|
|
105
|
+
let config;
|
|
106
|
+
try {
|
|
107
|
+
config = readConfigFile(absolutePath);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
printError(`Failed to read config file: ${error.message}`);
|
|
110
|
+
return exit(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
if (command === 'encrypt') {
|
|
115
|
+
const result = processConfig(config.data, { mode: 'encrypt', key, algorithm: options.algorithm });
|
|
116
|
+
writeConfigFile(absolutePath, config.format, result);
|
|
117
|
+
print(`Encrypted values in ${file}`);
|
|
118
|
+
return exit(0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (command === 'decrypt') {
|
|
122
|
+
const result = processConfig(config.data, { mode: 'decrypt', key });
|
|
123
|
+
writeConfigFile(absolutePath, config.format, result);
|
|
124
|
+
print(`Decrypted values in ${file}`);
|
|
125
|
+
return exit(0);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
printError(`Unknown command: ${command}`);
|
|
129
|
+
print(HELP_TEXT.trim());
|
|
130
|
+
return exit(1);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
printError(`Operation failed: ${error.message}`);
|
|
133
|
+
return exit(1);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
138
|
+
main();
|
|
139
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createDecipheriv } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
decodeFieldPathSalt,
|
|
5
|
+
deriveKey,
|
|
6
|
+
ensureAlgorithm,
|
|
7
|
+
isYamlockPayload,
|
|
8
|
+
parsePayload
|
|
9
|
+
} from './utils.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Decrypts a yamlock payload for the provided field path.
|
|
13
|
+
* @param {string} encryptedValue
|
|
14
|
+
* @param {string|Buffer} key
|
|
15
|
+
* @param {string} fieldPath
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
export function decryptValue(encryptedValue, key, fieldPath) {
|
|
19
|
+
if (!isYamlockPayload(encryptedValue)) {
|
|
20
|
+
throw new Error('decryptValue expects a yamlock-formatted payload.');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const payload = parsePayload(encryptedValue);
|
|
24
|
+
const normalizedAlgorithm = ensureAlgorithm(payload.algorithm);
|
|
25
|
+
const saltFieldPath = decodeFieldPathSalt(payload.salt);
|
|
26
|
+
|
|
27
|
+
if (!fieldPath) {
|
|
28
|
+
throw new Error('Field path is required to decrypt a value.');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (saltFieldPath !== fieldPath) {
|
|
32
|
+
throw new Error('Field path does not match the encrypted payload.');
|
|
33
|
+
}
|
|
34
|
+
|
|
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()]);
|
|
38
|
+
|
|
39
|
+
return decrypted.toString('utf8');
|
|
40
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createCipheriv } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
deriveKey,
|
|
5
|
+
encodeFieldPathSalt,
|
|
6
|
+
ensureAlgorithm,
|
|
7
|
+
formatPayload,
|
|
8
|
+
generateIv
|
|
9
|
+
} from './utils.js';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_ALGORITHM = 'aes-256-cbc';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Encrypts a string value for a specific configuration field path.
|
|
15
|
+
* @param {string} value
|
|
16
|
+
* @param {string|Buffer} key
|
|
17
|
+
* @param {string} fieldPath
|
|
18
|
+
* @param {string} [algorithm=DEFAULT_ALGORITHM]
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function encryptValue(value, key, fieldPath, algorithm = DEFAULT_ALGORITHM) {
|
|
22
|
+
if (typeof value !== 'string') {
|
|
23
|
+
throw new Error('encryptValue expects the value to be a string.');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const normalizedAlgorithm = ensureAlgorithm(algorithm);
|
|
27
|
+
const derivedKey = deriveKey(key, normalizedAlgorithm);
|
|
28
|
+
const iv = generateIv(normalizedAlgorithm);
|
|
29
|
+
const salt = encodeFieldPathSalt(fieldPath);
|
|
30
|
+
|
|
31
|
+
const cipher = createCipheriv(normalizedAlgorithm, derivedKey, iv);
|
|
32
|
+
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
|
|
33
|
+
|
|
34
|
+
return formatPayload({
|
|
35
|
+
algorithm: normalizedAlgorithm,
|
|
36
|
+
salt,
|
|
37
|
+
iv,
|
|
38
|
+
data: encrypted
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { createHash, getCipherInfo, getCiphers, randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const YAMLOCK_PREFIX = 'yl';
|
|
4
|
+
export const YAMLOCK_DELIMITER = '|';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns the sorted list of cipher algorithms supported by the current runtime.
|
|
8
|
+
* @returns {string[]}
|
|
9
|
+
*/
|
|
10
|
+
export function listSupportedAlgorithms() {
|
|
11
|
+
return [...new Set(getCiphers())].sort();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Ensures the provided algorithm is available in the current runtime.
|
|
16
|
+
* @param {string} algorithm
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
export function ensureAlgorithm(algorithm) {
|
|
20
|
+
if (!algorithm) {
|
|
21
|
+
throw new Error('Encryption algorithm is required.');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!listSupportedAlgorithms().includes(algorithm)) {
|
|
25
|
+
throw new Error(`Unsupported algorithm: ${algorithm}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return algorithm;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Derives a key buffer with the exact size required by the cipher.
|
|
33
|
+
* @param {string|Buffer} secret
|
|
34
|
+
* @param {string} algorithm
|
|
35
|
+
* @returns {Buffer}
|
|
36
|
+
*/
|
|
37
|
+
export function deriveKey(secret, algorithm) {
|
|
38
|
+
const normalizedAlgorithm = ensureAlgorithm(algorithm);
|
|
39
|
+
if (secret === undefined || secret === null || secret === '') {
|
|
40
|
+
throw new Error('Encryption key is required to derive a cipher key.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const baseBuffer = Buffer.isBuffer(secret)
|
|
44
|
+
? secret
|
|
45
|
+
: Buffer.from(String(secret), 'utf8');
|
|
46
|
+
|
|
47
|
+
const keyLength = getCipherInfo(normalizedAlgorithm)?.keyLength ?? 32;
|
|
48
|
+
if (baseBuffer.length === keyLength) {
|
|
49
|
+
return baseBuffer;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const hash = createHash('sha512').update(baseBuffer).digest();
|
|
53
|
+
if (hash.length >= keyLength) {
|
|
54
|
+
return hash.subarray(0, keyLength);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const result = Buffer.allocUnsafe(keyLength);
|
|
58
|
+
let offset = 0;
|
|
59
|
+
let material = hash;
|
|
60
|
+
while (offset < keyLength) {
|
|
61
|
+
const chunk = material.subarray(0, Math.min(material.length, keyLength - offset));
|
|
62
|
+
chunk.copy(result, offset);
|
|
63
|
+
offset += chunk.length;
|
|
64
|
+
material = createHash('sha512').update(material).digest();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Generates an IV buffer for the given algorithm (defaults to 16 bytes).
|
|
72
|
+
* @param {string} algorithm
|
|
73
|
+
* @returns {Buffer}
|
|
74
|
+
*/
|
|
75
|
+
export function generateIv(algorithm) {
|
|
76
|
+
const normalizedAlgorithm = ensureAlgorithm(algorithm);
|
|
77
|
+
const ivLength = getCipherInfo(normalizedAlgorithm)?.ivLength ?? 16;
|
|
78
|
+
if (ivLength === 0) {
|
|
79
|
+
return Buffer.alloc(0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return randomBytes(ivLength);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Creates a deterministic salt (Base64 field path) used when encrypting.
|
|
87
|
+
* @param {string} fieldPath
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
export function encodeFieldPathSalt(fieldPath) {
|
|
91
|
+
if (!fieldPath) {
|
|
92
|
+
throw new Error('Field path is required to create a salt.');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return Buffer.from(String(fieldPath), 'utf8').toString('base64');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Decodes a Base64 salt string back to the original field path.
|
|
100
|
+
* @param {string} salt
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
export function decodeFieldPathSalt(salt) {
|
|
104
|
+
if (!salt) {
|
|
105
|
+
throw new Error('Salt value is required.');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return Buffer.from(String(salt), 'base64').toString('utf8');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Formats the encrypted payload into the canonical yamlock string.
|
|
113
|
+
* @param {Object} payload
|
|
114
|
+
* @param {string} payload.algorithm
|
|
115
|
+
* @param {string} payload.salt
|
|
116
|
+
* @param {Buffer} payload.iv
|
|
117
|
+
* @param {Buffer} payload.data
|
|
118
|
+
* @returns {string}
|
|
119
|
+
*/
|
|
120
|
+
export function formatPayload({ algorithm, salt, iv, data }) {
|
|
121
|
+
if (!algorithm || !salt || !iv || !data) {
|
|
122
|
+
throw new Error('Algorithm, salt, IV, and data are required to format payload.');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return [
|
|
126
|
+
YAMLOCK_PREFIX,
|
|
127
|
+
algorithm,
|
|
128
|
+
salt,
|
|
129
|
+
iv.toString('base64'),
|
|
130
|
+
data.toString('base64')
|
|
131
|
+
].join(YAMLOCK_DELIMITER);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Checks whether a string looks like a yamlock payload.
|
|
136
|
+
* @param {unknown} candidate
|
|
137
|
+
* @returns {boolean}
|
|
138
|
+
*/
|
|
139
|
+
export function isYamlockPayload(candidate) {
|
|
140
|
+
return (
|
|
141
|
+
typeof candidate === 'string' &&
|
|
142
|
+
candidate.startsWith(`${YAMLOCK_PREFIX}${YAMLOCK_DELIMITER}`)
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Parses a yamlock payload and returns its structured pieces.
|
|
148
|
+
* @param {string} value
|
|
149
|
+
* @returns {{ algorithm: string, salt: string, iv: Buffer, data: Buffer }}
|
|
150
|
+
*/
|
|
151
|
+
export function parsePayload(value) {
|
|
152
|
+
if (!isYamlockPayload(value)) {
|
|
153
|
+
throw new Error('Value is not a yamlock payload.');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const parts = value.split(YAMLOCK_DELIMITER);
|
|
157
|
+
if (parts.length !== 5) {
|
|
158
|
+
throw new Error('Malformed yamlock payload.');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const [, algorithm, salt, ivBase64, dataBase64] = parts;
|
|
162
|
+
if (!algorithm || !salt || !ivBase64 || !dataBase64) {
|
|
163
|
+
throw new Error('Malformed yamlock payload segments.');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
algorithm,
|
|
168
|
+
salt,
|
|
169
|
+
iv: Buffer.from(ivBase64, 'base64'),
|
|
170
|
+
data: Buffer.from(dataBase64, 'base64')
|
|
171
|
+
};
|
|
172
|
+
}
|
package/dist/index.js
ADDED
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { encryptValue } from '../crypto/encrypt.js';
|
|
2
|
+
import { decryptValue } from '../crypto/decrypt.js';
|
|
3
|
+
import { buildPath } from './path.js';
|
|
4
|
+
|
|
5
|
+
const MODES = {
|
|
6
|
+
ENCRYPT: 'encrypt',
|
|
7
|
+
DECRYPT: 'decrypt'
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Recursively processes a config object or array, encrypting/decrypting string values.
|
|
12
|
+
* @param {Object|Array} node
|
|
13
|
+
* @param {Object} options
|
|
14
|
+
* @param {'encrypt'|'decrypt'} options.mode
|
|
15
|
+
* @param {string|Buffer} options.key
|
|
16
|
+
* @param {string} [options.algorithm]
|
|
17
|
+
* @param {Array<string|number>} [options.parentPath]
|
|
18
|
+
* @returns {Object|Array}
|
|
19
|
+
*/
|
|
20
|
+
export function processConfig(node, { mode, key, algorithm, parentPath = [] }) {
|
|
21
|
+
if (typeof node !== 'object' || node === null) {
|
|
22
|
+
throw new Error('processConfig expects a non-null object or array.');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (mode !== MODES.ENCRYPT && mode !== MODES.DECRYPT) {
|
|
26
|
+
throw new Error(`Unknown processConfig mode: ${mode}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isArrayNode = Array.isArray(node);
|
|
30
|
+
const result = isArrayNode ? [] : {};
|
|
31
|
+
|
|
32
|
+
Object.entries(node).forEach(([rawKey, value]) => {
|
|
33
|
+
const segment = isArrayNode ? Number(rawKey) : rawKey;
|
|
34
|
+
const targetKey = isArrayNode ? segment : rawKey;
|
|
35
|
+
const currentPath = buildPath(parentPath, segment);
|
|
36
|
+
|
|
37
|
+
if (value !== null && typeof value === 'object') {
|
|
38
|
+
result[targetKey] = processConfig(value, {
|
|
39
|
+
mode,
|
|
40
|
+
key,
|
|
41
|
+
algorithm,
|
|
42
|
+
parentPath: [...parentPath, segment]
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (typeof value !== 'string') {
|
|
48
|
+
result[targetKey] = value;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (mode === MODES.ENCRYPT) {
|
|
53
|
+
result[targetKey] = encryptValue(value, key, currentPath, algorithm);
|
|
54
|
+
} else {
|
|
55
|
+
result[targetKey] = decryptValue(value, key, currentPath);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a dot/bracket path string that uniquely identifies a value
|
|
3
|
+
* inside a nested object/array structure.
|
|
4
|
+
* Example: ["db", "users", 0, "password"] => "db.users[0].password"
|
|
5
|
+
*
|
|
6
|
+
* @param {Array<string|number>} segments
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
export function serializePath(segments) {
|
|
10
|
+
if (!Array.isArray(segments) || segments.length === 0) {
|
|
11
|
+
throw new Error('serializePath requires a non-empty segments array.');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return segments
|
|
15
|
+
.map((segment, index) => {
|
|
16
|
+
if (typeof segment === 'number') {
|
|
17
|
+
return `[${segment}]`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (typeof segment === 'string' && segment.length > 0) {
|
|
21
|
+
return index === 0 ? segment : `.${segment}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
throw new Error('Path segments must be non-empty strings or numbers.');
|
|
25
|
+
})
|
|
26
|
+
.join('');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Returns the full path string for a given traversal context.
|
|
31
|
+
* @param {Array<string|number>} parentSegments
|
|
32
|
+
* @param {string|number} currentSegment
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function buildPath(parentSegments, currentSegment) {
|
|
36
|
+
const segments = [...(parentSegments ?? [])];
|
|
37
|
+
segments.push(currentSegment);
|
|
38
|
+
return serializePath(segments);
|
|
39
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "yamlock",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Value-level encryption for YAML/JSON configuration files with CLI + Node.js APIs.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"yamlock": "bin/yamlock"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"bin",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"keywords": [
|
|
24
|
+
"yaml",
|
|
25
|
+
"cli",
|
|
26
|
+
"config",
|
|
27
|
+
"json",
|
|
28
|
+
"encryption",
|
|
29
|
+
"security"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=22.0.0"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "rimraf dist && cp -R src dist",
|
|
36
|
+
"lint": "eslint .",
|
|
37
|
+
"prepare": "yarn run build",
|
|
38
|
+
"test": "node --test"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/phoenixweiss/yamlock.git"
|
|
43
|
+
},
|
|
44
|
+
"bugs": {
|
|
45
|
+
"url": "https://github.com/phoenixweiss/yamlock/issues"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://github.com/phoenixweiss/yamlock#readme",
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"js-yaml": "^4.1.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@eslint/js": "^9.0.0",
|
|
53
|
+
"eslint": "^9.0.0",
|
|
54
|
+
"globals": "^15.0.0",
|
|
55
|
+
"rimraf": "^5.0.5"
|
|
56
|
+
}
|
|
57
|
+
}
|