yamlock 0.2.9 → 1.0.0

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
@@ -5,7 +5,7 @@
5
5
  ```
6
6
 
7
7
  [![npm version](https://img.shields.io/npm/v/yamlock)](https://www.npmjs.com/package/yamlock)
8
- [![Tests](https://img.shields.io/badge/tests-node--test-green)](https://github.com/phoenixweiss/yamlock/actions)
8
+ [![CI](https://github.com/phoenixweiss/yamlock/actions/workflows/ci.yml/badge.svg)](https://github.com/phoenixweiss/yamlock/actions/workflows/ci.yml)
9
9
 
10
10
  # yamlock
11
11
 
@@ -13,8 +13,10 @@ Value-level encryption for YAML and JSON configuration files. The name **yamlock
13
13
 
14
14
  ## Requirements
15
15
 
16
- - Node.js 22.x via `asdf`
17
- - Yarn Classic (1.x)
16
+ - Node.js 22 or newer
17
+
18
+ The package works with npm or another Node.js package manager. This repository
19
+ uses Yarn Classic 1.22.22 for development and release checks.
18
20
 
19
21
  ## Installation
20
22
 
@@ -34,8 +36,9 @@ yarn add yamlock # project dependency
34
36
 
35
37
  ## Features
36
38
 
37
- - Encrypt/decrypt individual configuration values with deterministic field-path salts.
39
+ - Encrypt/decrypt individual values with authenticated field-path metadata.
38
40
  - CLI workflow that processes YAML or JSON files in place.
41
+ - Safe CLI migration from legacy payloads to authenticated v2 payloads.
39
42
  - Recursively lock/unlock entire objects via `processConfig`.
40
43
  - Public API exports that mirror CLI behavior for programmatic use.
41
44
  - Focus on Node.js 22+, ESM modules, and a lightweight dependency set (`js-yaml`).
@@ -48,34 +51,97 @@ yarn add yamlock # project dependency
48
51
  # Encrypt values in a YAML file
49
52
  YAMLOCK_KEY="super-secret" yamlock encrypt config.yaml
50
53
 
51
- # Decrypt values in place using explicit key/algorithm flags
52
- yamlock decrypt settings.json --key "super-secret" --algorithm aes-256-cbc
54
+ # Decrypt values in place; the payload determines its format and algorithm
55
+ yamlock decrypt settings.json --key "super-secret"
53
56
 
54
57
  # Encrypt only selected fields into a new file
55
58
  yamlock encrypt config.json --key "$YAMLOCK_KEY" --paths "db.password,api.token" --output config.secure.json
56
59
 
57
60
  # Inspect CLI metadata
61
+ yamlock --help
58
62
  yamlock version
59
63
  yamlock algorithms
60
64
 
61
65
  # Generate a random key for YAMLOCK_KEY
62
66
  yamlock keygen --length 64 --format base64
67
+
68
+ # Preview changes without touching files
69
+ yamlock encrypt config.yml -o config.enc.yml -p db.password -k "my-secret-key" -d
70
+
71
+ # Preview a legacy-to-v2 migration without printing config contents
72
+ yamlock migrate config.yml -k "$YAMLOCK_KEY" -p db.password -d
73
+
74
+ # Explicitly write legacy v1 for a temporary compatibility requirement
75
+ yamlock encrypt config.yml -k "$YAMLOCK_KEY" --legacy --algorithm aes-256-cbc
63
76
  ```
64
77
 
65
- The CLI detects YAML (`.yaml`/`.yml`) and JSON extensions automatically and writes the file back in the same format.
78
+ The CLI detects YAML (`.yaml`/`.yml`) and JSON extensions automatically and
79
+ writes the file back in the same format. Normal `encrypt` and `decrypt` writes
80
+ use an fsynced temporary file plus an atomic rename. In-place writes preserve
81
+ the source mode; a new `--output` inherits the source mode, while an existing
82
+ regular output preserves its own mode. Mutating operations reject symbolic-link
83
+ inputs and outputs rather than following or replacing their targets.
84
+
85
+ YAML writes preserve parsed values, not the original syntax tree. Comments,
86
+ anchor/alias syntax, merge keys, explicit tags, quoting, and formatting may be
87
+ removed or normalized. Review [the YAML rewrite contract](docs/yaml-behavior.md)
88
+ and use `--dry-run` or `--output` when presentation details matter.
66
89
 
67
90
  Options of note:
68
91
  - `--output <file>` writes the result to a separate file instead of overwriting the input.
69
- - `--paths <path1,path2>` targets only the specified fields (dot/bracket notation like `db.password` or `users[0].token`).
70
- - `--dry-run` shows the would-be changes without modifying files (prints original vs new content).
71
- - Command `keygen` produces a random key and shows how to store it (shell export or `.env`).
72
- - Command `algorithms` prints two lists: tested presets (covered by yamlock) and additional ciphers available from the runtime.
92
+ - `--paths <path1,path2>` targets only the specified fields using the [escaped path syntax](#field-path-syntax).
93
+ - `--dry-run` previews an operation without modifying files; encrypt/decrypt print content changes, while migrate prints only counts and target paths.
94
+ - `migrate` decrypts selected legacy payloads and re-encrypts them as authenticated v2 payloads.
95
+ - `migrate --allow-mixed` additionally authenticates and preserves selected values that are already v2.
96
+ - In-place migration creates `<file>.yamlock.bak` by default; `--no-backup` disables it explicitly.
97
+ - `encrypt --legacy` writes the legacy v1 format; `--algorithm` is accepted for encryption only together with `--legacy`.
98
+ - `encrypt --error-on-encrypted` fails when a selected value is already encrypted instead of preserving it.
99
+ - `encrypt --force-encrypt` explicitly treats selected `yl|...` strings as plaintext and adds another encryption layer.
100
+ - Command `keygen` produces a random key of 1–4096 whole bytes and shows how to store it (shell export or `.env`).
101
+ - Command `algorithms` prints the fixed v2 profile, tested legacy presets, and additional legacy ciphers available from the runtime.
73
102
  - Command `version` prints the installed CLI version.
74
103
 
104
+ The CLI rejects unknown or duplicate options, missing option values, extra
105
+ positional arguments, and options that do not apply to the selected command.
106
+ Argument errors use structured `[yamlock:ERR_*]` codes and are reported before
107
+ the CLI reads or modifies a configuration file.
108
+
109
+ ### Field path syntax
110
+
111
+ Default field paths use dots for object nesting and brackets for array indexes:
112
+ `db.password` and `users[0].token`. Inside an object key, backslashes, dots,
113
+ brackets, and commas are escaped with a backslash. For example,
114
+ `db\.primary.token` selects `token` below the literal key `db.primary`, while
115
+ `db.primary.token` selects three nested object keys. Quote escaped CLI paths so
116
+ the shell passes each backslash unchanged:
117
+
118
+ ```bash
119
+ yamlock encrypt config.json --key "$YAMLOCK_KEY" --paths 'db\.primary.token,labels\,primary'
120
+ ```
121
+
122
+ Node.js callers can build the same canonical strings from unambiguous segments:
123
+
124
+ ```js
125
+ import { processConfig, serializePath } from 'yamlock';
126
+
127
+ const selectedPath = serializePath(['db.primary', 'token']);
128
+ const encrypted = processConfig(config, {
129
+ mode: 'encrypt',
130
+ key: process.env.YAMLOCK_KEY,
131
+ paths: [selectedPath]
132
+ });
133
+ ```
134
+
135
+ New payloads bind authentication to the escaped canonical path. Payloads written
136
+ by older yamlock versions for keys containing reserved characters remain
137
+ readable through the default serializer's compatibility path; selecting those
138
+ keys now requires the canonical escaped spelling. A custom `pathSerializer`
139
+ keeps its own contract and does not use the default compatibility fallback.
140
+
75
141
  ### Node.js API
76
142
 
77
143
  ```js
78
- import { encryptValue, decryptValue, processConfig } from 'yamlock';
144
+ import { encryptValue, decryptValue, processConfig, serializePath } from 'yamlock';
79
145
 
80
146
  const encrypted = encryptValue('swordfish', process.env.YAMLOCK_KEY, 'db.password');
81
147
  const decrypted = decryptValue(encrypted, process.env.YAMLOCK_KEY, 'db.password');
@@ -85,14 +151,49 @@ const locked = processConfig(config, { mode: 'encrypt', key: process.env.YAMLOCK
85
151
  const unlocked = processConfig(locked, { mode: 'decrypt', key: process.env.YAMLOCK_KEY });
86
152
  ```
87
153
 
154
+ Expected Node.js API failures extend `YamlockError` and expose stable `ERR_*`
155
+ codes. Specialized classes distinguish validation, payload, authentication,
156
+ legacy decryption, and config-processing failures; `YAMLOCK_ERROR_CODES` avoids
157
+ repeating code strings. See the [Node.js error contract](docs/errors.md).
158
+
159
+ The package includes TypeScript declarations. The supported exports, option
160
+ types, and `1.x` compatibility guarantees are listed in the
161
+ [public Node.js API contract](docs/api.md).
162
+
163
+ Repeated encryption is safe by default. `processConfig` authenticates selected
164
+ existing payloads with the supplied key and field path, preserves them
165
+ unchanged, and encrypts only selected plaintext values. Use
166
+ `existingPayloadPolicy: 'error'` when existing encrypted values should fail the
167
+ operation:
168
+
169
+ ```js
170
+ processConfig(config, {
171
+ mode: 'encrypt',
172
+ key: process.env.YAMLOCK_KEY,
173
+ existingPayloadPolicy: 'error'
174
+ });
175
+ ```
176
+
177
+ Malformed payloads, incorrect keys, and incorrect field paths are never silently
178
+ skipped. Re-running `yamlock encrypt` on a fully encrypted input does not rewrite
179
+ the source file. Use `yamlock migrate` rather than `encrypt` to convert preserved
180
+ legacy values to v2.
181
+
182
+ If plaintext intentionally begins with `yl|`, use
183
+ `existingPayloadPolicy: 'encrypt'` or CLI `--force-encrypt`. This also permits
184
+ deliberate nested encryption, so it should not be enabled in routine workflows;
185
+ each added layer requires a matching decrypt operation.
186
+
88
187
  See `examples/basic.js` for a runnable end-to-end script (`node examples/basic.js`).
89
188
 
90
- ### Algorithm customization
189
+ ### Legacy algorithm customization
91
190
 
92
- Each function accepts either a cipher name or an options object:
191
+ V2 deliberately has no free-form cipher settings. For temporary legacy
192
+ compatibility, select format version 1 and provide the old cipher options:
93
193
 
94
194
  ```js
95
195
  const encrypted = encryptValue('swordfish', KEY, 'db.password', {
196
+ formatVersion: 1,
96
197
  algorithm: 'chacha20-poly1305',
97
198
  ivLength: 12 // override the IV size used during encryption
98
199
  });
@@ -107,6 +208,7 @@ const processed = processConfig(
107
208
  {
108
209
  mode: 'encrypt',
109
210
  key: KEY,
211
+ formatVersion: 1,
110
212
  algorithm: { algorithm: 'aes-192-cbc', ivLength: 24 }
111
213
  }
112
214
  );
@@ -115,52 +217,160 @@ const processed = processConfig(
115
217
  const restored = processConfig(processed, {
116
218
  mode: 'decrypt',
117
219
  key: KEY,
220
+ formatVersion: 1,
118
221
  algorithm: { algorithm: 'aes-192-cbc', ivLength: 24 }
119
222
  });
223
+
224
+ // Control what happens when encountering non-string values and customize path IDs
225
+ const mixedConfig = { db: { password: 'secret', retries: 3 } };
226
+ const lockedMixed = processConfig(mixedConfig, {
227
+ mode: 'encrypt',
228
+ key: KEY,
229
+ nonStringPolicy: 'stringify', // stringifies finite numbers, booleans, and null
230
+ pathSerializer: (segments) => segments.join('/') // custom path naming (db/password instead of dot notation)
231
+ });
232
+
233
+ // Example of a path serializer that includes array indexes explicitly
234
+ const lockedUsers = processConfig(
235
+ { users: [{ tokens: ['abc'] }] },
236
+ {
237
+ mode: 'encrypt',
238
+ key: KEY,
239
+ pathSerializer: (segments) =>
240
+ segments
241
+ .map((segment, index) =>
242
+ typeof segment === 'number' ? `[${segment}]` : index === 0 ? segment : `/${segment}`
243
+ )
244
+ .join('')
245
+ }
246
+ );
247
+ ```
248
+
249
+ `processConfig` recursively traverses arrays and plain objects. With the default
250
+ `nonStringPolicy: 'ignore'`, selected non-string and opaque values such as
251
+ `Date`, `undefined`, or `BigInt` are preserved unchanged. Policy `'error'`
252
+ rejects a selected non-string value. Policy `'stringify'` accepts only finite
253
+ numbers, booleans, and `null`; values that native JSON conversion could omit or
254
+ silently change are rejected instead of being encrypted with lost type
255
+ information. Decrypted stringified primitives remain strings.
256
+
257
+ Circular arrays/objects are rejected. A custom `pathSerializer` must return a
258
+ non-empty, unique string for every leaf and the same serializer must be used for
259
+ encryption and decryption. Policies apply only to values selected by `paths`;
260
+ unselected leaf values are preserved.
261
+
262
+ Direct `processConfig` calls preserve empty containers, sparse-array length and
263
+ holes, null-prototype objects, and own keys such as `__proto__`. JSON and YAML
264
+ files retain the values their parsers can represent, but those formats do not
265
+ encode JavaScript sparse-array holes as a distinct portable value.
266
+
267
+ ### Authenticated payload v2 (default)
268
+
269
+ `encryptValue`, `processConfig`, and `yamlock encrypt` write authenticated v2
270
+ payloads by default. `decryptValue` and `processConfig` automatically read both
271
+ v1 and v2.
272
+
273
+ ```js
274
+ const encrypted = encryptValue('swordfish', KEY, 'db.password');
275
+
276
+ const locked = processConfig(
277
+ { db: { password: 'swordfish' } },
278
+ { mode: 'encrypt', key: KEY }
279
+ );
280
+
281
+ // No format or algorithm option is required when decrypting.
282
+ const original = decryptValue(encrypted, KEY, 'db.password');
283
+ const unlocked = processConfig(locked, { mode: 'decrypt', key: KEY });
284
+ ```
285
+
286
+ V2 uses a fixed AES-256-GCM profile, a random 12-byte nonce, a 16-byte
287
+ authentication tag, and scrypt with a separate random KDF salt. The field path
288
+ and security-critical metadata are authenticated. Free-form cipher and size
289
+ overrides are intentionally unavailable for v2.
290
+
291
+ Existing legacy files can be migrated safely with the CLI:
292
+
293
+ ```bash
294
+ # Preview counts and target paths; config contents are not printed.
295
+ yamlock migrate config.yaml --key "$YAMLOCK_KEY" --paths "db.password,api.token" --dry-run
296
+
297
+ # Migrate in place and create config.yaml.yamlock.bak.
298
+ yamlock migrate config.yaml --key "$YAMLOCK_KEY" --paths "db.password,api.token"
299
+
300
+ # Preserve the source and write a new file. Existing outputs are never replaced.
301
+ yamlock migrate config.yaml --key "$YAMLOCK_KEY" --paths "db.password,api.token" --output config.v2.yaml
120
302
  ```
121
303
 
304
+ Migration validates every selected value and builds the complete result before
305
+ writing. Selected plaintext and non-string values are rejected, so use
306
+ `--paths` for partially encrypted configs. Selected v2 values are rejected
307
+ unless `--allow-mixed` is set; with that flag they are authenticated and kept
308
+ unchanged. In-place writes are atomic, preserve the source file mode, and do
309
+ not replace an existing backup. To roll back, verify the backup and then copy
310
+ `config.yaml.yamlock.bak` over `config.yaml`.
311
+
312
+ Legacy AES-CBC payloads have no authentication, so migration can only validate
313
+ their structure, field path, and successful decryption. Backups use the source
314
+ file permissions and match `*.yamlock.bak` in the repository `.gitignore`.
315
+ See [the payload v2 design](docs/design/payload-v2.md) for the format, threat
316
+ model, limits, and staged migration plan. This design and implementation have
317
+ not received a third-party security audit.
318
+
122
319
  ## Advanced usage
123
320
 
124
- - **Selective encryption**: combine `--paths` on the CLI or `paths: []` in `processConfig` to encrypt only sensitive sections of a config file.
321
+ - **Selective encryption**: combine `--paths` on the CLI or a non-empty `paths: ['db.password']` array in `processConfig` to encrypt only sensitive fields.
322
+ - **Repeated encryption**: valid selected payloads are authenticated and preserved; add `--error-on-encrypted` or `existingPayloadPolicy: 'error'` for strict workflows.
323
+ - **Non-string handling**: use `nonStringPolicy: 'ignore' | 'stringify' | 'error'` to preserve opaque leaves, stringify finite JSON primitives, or reject selected non-string values; use `pathSerializer` to change path representation (e.g., `db/password` instead of dot notation).
125
324
  - **CI/CD flows**: see [examples/docs/ci-cd.md](examples/docs/ci-cd.md) for a GitHub Actions job that decrypts configs for builds and re-encrypts them before publishing artifacts.
126
325
  - **Key rotation**: follow [examples/docs/key-rotation.md](examples/docs/key-rotation.md) for a step-by-step process, including scripting tips for large repos.
127
326
 
128
327
  ### Supported algorithms
129
328
 
130
- | Algorithm | Type | Notes |
131
- |-----------|------|-------|
132
- | `aes-128-cbc` | Block cipher (CBC) | 128-bit keys, 16-byte IV. Works well for backward-compatibility scenarios. |
133
- | `aes-192-cbc` | Block cipher (CBC) | 192-bit keys, 16-byte IV. Slightly stronger than AES-128 with the same IV requirements. |
134
- | `aes-256-cbc` (default) | Block cipher (CBC) | 256-bit keys, 16-byte IV. Balanced combination of strength and compatibility. |
135
- | `chacha20-poly1305` | AEAD stream cipher | 256-bit keys, 12-byte nonce, 16-byte auth tag. Provides built-in integrity/authentication. |
329
+ | Algorithm | Format | Notes |
330
+ |-----------|--------|-------|
331
+ | `aes-256-gcm` | v2 default | Fixed authenticated profile with scrypt, a 12-byte nonce, and a 16-byte tag. |
332
+ | `aes-128-cbc` | legacy v1 | Compatibility only; ciphertext and metadata are not authenticated. |
333
+ | `aes-192-cbc` | legacy v1 | Compatibility only; ciphertext and metadata are not authenticated. |
334
+ | `aes-256-cbc` | legacy v1 | Compatibility default when `--legacy` is used without `--algorithm`. |
335
+ | `chacha20-poly1305` | legacy v1 | Authenticates ciphertext, but not all serialized metadata protected by v2. |
136
336
 
137
- 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.
337
+ Additional algorithms exposed by `crypto.getCiphers()` are available only in
338
+ explicit legacy mode and are not part of the supported v2 profile. Prefer the
339
+ default v2 writer for new data.
138
340
 
139
341
  ## Release information
140
342
 
141
- - The badges at the top show the latest npm version and the status of the Node test suite.
343
+ - The badges at the top show the latest npm version and the status of the full CI matrix.
142
344
  - See [CHANGELOG.md](CHANGELOG.md) for detailed release notes; install a specific tag via `npm install yamlock@<version>`.
345
+ - yamlock versions are bumped with my own release utility,
346
+ [Bumpster](https://github.com/phoenixweiss/Bumpster). It keeps the tracked
347
+ `VERSION` file and `package.json` synchronized while publishing the `dev`,
348
+ `main`, and `vX.Y.Z` Git refs atomically.
143
349
 
144
- ### Encrypted value format
350
+ ### Encrypted value formats
145
351
 
146
- Every locked string follows the format:
352
+ New values use the authenticated v2 envelope:
353
+
354
+ ```txt
355
+ yl|2|aes-256-gcm|scrypt|32768|8|1|<kdf_salt>|<nonce>|<path>|<ciphertext>|<tag>
356
+ ```
357
+
358
+ Legacy values remain readable and can still be written explicitly:
147
359
 
148
360
  ```txt
149
361
  yl|<algorithm>|<salt_base64>|<iv_base64>|<data_base64>
150
362
  ```
151
363
 
152
- Where:
153
- - `yl` - format marker prefix
154
- - `<algorithm>` - algorithm name (e.g., aes-256-cbc)
155
- - `<salt_base64>` - Base64-encoded field path
156
- - `<iv_base64>` - Base64-encoded initialization vector
157
- - `<data_base64>` - Base64-encoded encrypted data
364
+ The legacy name `<salt_base64>` is historical: that segment is only the
365
+ Base64-encoded field path and is not a random salt or KDF input.
158
366
 
159
- The salt is derived from the full field path. Moving or renaming the field invalidates the salt, preventing accidental decryption in the wrong location.
367
+ See [the payload v2 design](docs/design/payload-v2.md) for the canonical field
368
+ definitions, limits, compatibility rules, and legacy security limitations.
160
369
 
161
370
  ### Key rotation
162
371
 
163
- See [docs/key-rotation.md](docs/key-rotation.md) for a step-by-step guide to rotating `YAMLOCK_KEY` without losing data.
372
+ See [the key rotation guide](examples/docs/key-rotation.md) for a step-by-step
373
+ workflow that re-encrypts values with v2 and a new `YAMLOCK_KEY`.
164
374
 
165
375
  ## Inspiration and motivation
166
376
 
@@ -183,9 +393,12 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow, available scrip
183
393
 
184
394
  ## Future work
185
395
 
186
- - Additional cipher presets and stronger default algorithms.
187
- - More CLI/API examples for rotating keys, selective field targeting, and CI automation.
188
- - Configurable behavior for non-string values (skip vs. coerce) and stricter file format validation.
396
+ - An async encryption API with bounded scrypt concurrency for large configs.
397
+ - Stricter file-format validation and preservation rules for advanced YAML features.
398
+
399
+ ## Author
400
+
401
+ Created and maintained by [Pavel Tkachev (@phoenixweiss)](https://github.com/phoenixweiss).
189
402
 
190
403
  ## License
191
404
 
package/bin/yamlock CHANGED
@@ -1,6 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  async function loadCliEntry() {
4
+ if (process.env.YAMLOCK_TEST_SOURCE === '1') {
5
+ const sourceModule = await import('../src/cli/cli.js');
6
+ if (sourceModule.runCli) {
7
+ return sourceModule.runCli;
8
+ }
9
+ throw new Error('Source CLI entry does not export runCli.');
10
+ }
11
+
4
12
  try {
5
13
  const distModule = await import('../dist/cli/cli.js');
6
14
  if (distModule.runCli) {