yamlock 0.3.0 → 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,13 +51,14 @@ 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
 
@@ -63,22 +67,81 @@ yamlock keygen --length 64 --format base64
63
67
 
64
68
  # Preview changes without touching files
65
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
66
76
  ```
67
77
 
68
- 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.
69
89
 
70
90
  Options of note:
71
91
  - `--output <file>` writes the result to a separate file instead of overwriting the input.
72
- - `--paths <path1,path2>` targets only the specified fields (dot/bracket notation like `db.password` or `users[0].token`).
73
- - `--dry-run` shows the would-be changes without modifying files (prints original vs new content).
74
- - Command `keygen` produces a random key and shows how to store it (shell export or `.env`).
75
- - 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.
76
102
  - Command `version` prints the installed CLI version.
77
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
+
78
141
  ### Node.js API
79
142
 
80
143
  ```js
81
- import { encryptValue, decryptValue, processConfig } from 'yamlock';
144
+ import { encryptValue, decryptValue, processConfig, serializePath } from 'yamlock';
82
145
 
83
146
  const encrypted = encryptValue('swordfish', process.env.YAMLOCK_KEY, 'db.password');
84
147
  const decrypted = decryptValue(encrypted, process.env.YAMLOCK_KEY, 'db.password');
@@ -88,14 +151,49 @@ const locked = processConfig(config, { mode: 'encrypt', key: process.env.YAMLOCK
88
151
  const unlocked = processConfig(locked, { mode: 'decrypt', key: process.env.YAMLOCK_KEY });
89
152
  ```
90
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
+
91
187
  See `examples/basic.js` for a runnable end-to-end script (`node examples/basic.js`).
92
188
 
93
- ### Algorithm customization
189
+ ### Legacy algorithm customization
94
190
 
95
- 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:
96
193
 
97
194
  ```js
98
195
  const encrypted = encryptValue('swordfish', KEY, 'db.password', {
196
+ formatVersion: 1,
99
197
  algorithm: 'chacha20-poly1305',
100
198
  ivLength: 12 // override the IV size used during encryption
101
199
  });
@@ -110,6 +208,7 @@ const processed = processConfig(
110
208
  {
111
209
  mode: 'encrypt',
112
210
  key: KEY,
211
+ formatVersion: 1,
113
212
  algorithm: { algorithm: 'aes-192-cbc', ivLength: 24 }
114
213
  }
115
214
  );
@@ -118,6 +217,7 @@ const processed = processConfig(
118
217
  const restored = processConfig(processed, {
119
218
  mode: 'decrypt',
120
219
  key: KEY,
220
+ formatVersion: 1,
121
221
  algorithm: { algorithm: 'aes-192-cbc', ivLength: 24 }
122
222
  });
123
223
 
@@ -126,7 +226,7 @@ const mixedConfig = { db: { password: 'secret', retries: 3 } };
126
226
  const lockedMixed = processConfig(mixedConfig, {
127
227
  mode: 'encrypt',
128
228
  key: KEY,
129
- nonStringPolicy: 'stringify', // stringifies numbers/objects before encrypting
229
+ nonStringPolicy: 'stringify', // stringifies finite numbers, booleans, and null
130
230
  pathSerializer: (segments) => segments.join('/') // custom path naming (db/password instead of dot notation)
131
231
  });
132
232
 
@@ -146,49 +246,131 @@ const lockedUsers = processConfig(
146
246
  );
147
247
  ```
148
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
302
+ ```
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
+
149
319
  ## Advanced usage
150
320
 
151
- - **Selective encryption**: combine `--paths` on the CLI or `paths: []` in `processConfig` to encrypt only sensitive sections of a config file.
152
- - **Non-string handling**: use `nonStringPolicy: 'ignore' | 'stringify' | 'error'` to control how numbers/objects are treated, and `pathSerializer` to change how traversal paths are represented (e.g., `db/password` instead of dot notation).
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).
153
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.
154
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.
155
326
 
156
327
  ### Supported algorithms
157
328
 
158
- | Algorithm | Type | Notes |
159
- |-----------|------|-------|
160
- | `aes-128-cbc` | Block cipher (CBC) | 128-bit keys, 16-byte IV. Works well for backward-compatibility scenarios. |
161
- | `aes-192-cbc` | Block cipher (CBC) | 192-bit keys, 16-byte IV. Slightly stronger than AES-128 with the same IV requirements. |
162
- | `aes-256-cbc` (default) | Block cipher (CBC) | 256-bit keys, 16-byte IV. Balanced combination of strength and compatibility. |
163
- | `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. |
164
336
 
165
- 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.
166
340
 
167
341
  ## Release information
168
342
 
169
- - 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.
170
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.
171
349
 
172
- ### Encrypted value format
350
+ ### Encrypted value formats
173
351
 
174
- 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:
175
359
 
176
360
  ```txt
177
361
  yl|<algorithm>|<salt_base64>|<iv_base64>|<data_base64>
178
362
  ```
179
363
 
180
- Where:
181
- - `yl` - format marker prefix
182
- - `<algorithm>` - algorithm name (e.g., aes-256-cbc)
183
- - `<salt_base64>` - Base64-encoded field path
184
- - `<iv_base64>` - Base64-encoded initialization vector
185
- - `<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.
186
366
 
187
- 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.
188
369
 
189
370
  ### Key rotation
190
371
 
191
- 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`.
192
374
 
193
375
  ## Inspiration and motivation
194
376
 
@@ -211,9 +393,12 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow, available scrip
211
393
 
212
394
  ## Future work
213
395
 
214
- - Additional cipher presets and stronger default algorithms.
215
- - More CLI/API examples for rotating keys, selective field targeting, and CI automation.
216
- - 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).
217
402
 
218
403
  ## License
219
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) {