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.
@@ -1,15 +1,46 @@
1
+ import {
2
+ YAMLOCK_ERROR_CODES,
3
+ YamlockValidationError
4
+ } from '../errors.js';
5
+
6
+ const RESERVED_PATH_CHARACTERS = new Set(['\\', '.', '[', ']', ',']);
7
+
8
+ function validateSegments(segments, functionName) {
9
+ if (!Array.isArray(segments) || segments.length === 0) {
10
+ throw new YamlockValidationError(
11
+ `${functionName} requires a non-empty segments array.`,
12
+ { code: YAMLOCK_ERROR_CODES.INVALID_PATH_SEGMENTS }
13
+ );
14
+ }
15
+
16
+ const invalid = segments.some((segment) => (
17
+ (typeof segment !== 'string' || segment.length === 0) &&
18
+ (!Number.isInteger(segment) || segment < 0)
19
+ ));
20
+ if (invalid) {
21
+ throw new YamlockValidationError(
22
+ 'Path segments must be non-empty strings or non-negative integers.',
23
+ { code: YAMLOCK_ERROR_CODES.INVALID_PATH_SEGMENTS }
24
+ );
25
+ }
26
+ }
27
+
28
+ function escapeStringSegment(segment) {
29
+ return [...segment]
30
+ .map((character) => RESERVED_PATH_CHARACTERS.has(character) ? `\\${character}` : character)
31
+ .join('');
32
+ }
33
+
1
34
  /**
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"
35
+ * Builds a canonical dot/bracket path that uniquely identifies a value.
36
+ * Reserved characters in object keys are escaped with a backslash.
37
+ * Example: ["db.settings", "users", 0] => "db\\.settings.users[0]"
5
38
  *
6
39
  * @param {Array<string|number>} segments
7
40
  * @returns {string}
8
41
  */
9
42
  export function serializePath(segments) {
10
- if (!Array.isArray(segments) || segments.length === 0) {
11
- throw new Error('serializePath requires a non-empty segments array.');
12
- }
43
+ validateSegments(segments, 'serializePath');
13
44
 
14
45
  return segments
15
46
  .map((segment, index) => {
@@ -17,11 +48,29 @@ export function serializePath(segments) {
17
48
  return `[${segment}]`;
18
49
  }
19
50
 
20
- if (typeof segment === 'string' && segment.length > 0) {
21
- return index === 0 ? segment : `.${segment}`;
51
+ const escaped = escapeStringSegment(segment);
52
+ return index === 0 ? escaped : `.${escaped}`;
53
+ })
54
+ .join('');
55
+ }
56
+
57
+ /**
58
+ * Reproduces the path representation written by yamlock before escaping was
59
+ * introduced. This is used only to read existing payloads.
60
+ *
61
+ * @param {Array<string|number>} segments
62
+ * @returns {string}
63
+ */
64
+ export function serializeLegacyPath(segments) {
65
+ validateSegments(segments, 'serializeLegacyPath');
66
+
67
+ return segments
68
+ .map((segment, index) => {
69
+ if (typeof segment === 'number') {
70
+ return `[${segment}]`;
22
71
  }
23
72
 
24
- throw new Error('Path segments must be non-empty strings or numbers.');
73
+ return index === 0 ? segment : `.${segment}`;
25
74
  })
26
75
  .join('');
27
76
  }
package/docs/api.md ADDED
@@ -0,0 +1,56 @@
1
+ # Public Node.js API
2
+
3
+ yamlock `1.x` exposes one ESM entry point. The supported package exports are:
4
+
5
+ - `encryptValue(value, key, fieldPath, options?)`
6
+ - `decryptValue(payload, key, fieldPath, options?)`
7
+ - `processConfig(config, options)`
8
+ - `serializePath(segments)`
9
+ - `getSupportedAlgorithms()`
10
+ - `YAMLOCK_ERROR_CODES` and the documented `YamlockError` class hierarchy
11
+
12
+ The package includes TypeScript declarations for these values and for their
13
+ options, path segments, keys, config containers, and error codes. Internal files
14
+ under `dist/` are implementation details and are intentionally unavailable as
15
+ package subpath exports.
16
+
17
+ ## Stability contract for 1.x
18
+
19
+ - The default writer produces authenticated v2 payloads. Their serialized
20
+ fields, KDF profile, limits, and field-path binding remain compatible
21
+ throughout `1.x`.
22
+ - Legacy payload reading remains available throughout `1.x`. Explicit legacy
23
+ writing through `formatVersion: 1`, legacy algorithm options, or CLI
24
+ `--legacy` is a deprecated compatibility path, but will not be removed before
25
+ a future major release.
26
+ - Canonical path serialization and the legacy-path read fallback remain
27
+ compatible throughout `1.x`.
28
+ - Existing exports, documented option names, return types, error classes, and
29
+ error codes will not be removed or incompatibly redefined in a minor or patch
30
+ release.
31
+ - New optional exports, options, and error codes may be added in a minor
32
+ release. Human-readable error messages may be clarified without changing the
33
+ stable error code.
34
+ - The synchronous API remains supported. A future async API, if added, will be
35
+ additive rather than replacing the synchronous functions in `1.x`.
36
+
37
+ ## Crypto options
38
+
39
+ With no crypto options, `encryptValue` and `processConfig` write v2 payloads.
40
+ V2 accepts `formatVersion: 2` and the fixed `aes-256-gcm` profile; free-form
41
+ algorithm sizing is rejected.
42
+
43
+ Legacy compatibility accepts `formatVersion: 1`, an algorithm string, or an
44
+ options object with `algorithm`, `keyLength`, `ivLength`, and `authTagLength`.
45
+ The algorithm stored in a payload is authoritative during decryption; sizing
46
+ overrides exist only for low-level legacy compatibility.
47
+
48
+ `processConfig` additionally accepts exact `paths`, `parentPath`, a custom
49
+ `pathSerializer`, `nonStringPolicy`, and encrypt-only
50
+ `existingPayloadPolicy`. It returns a new config container and does not mutate
51
+ the input. With `nonStringPolicy: 'stringify'`, selected finite JSON primitives
52
+ may become strings, so the TypeScript return type is intentionally widened.
53
+
54
+ See the [Node.js error contract](errors.md) and the
55
+ [payload v2 design](design/payload-v2.md) for the security and serialization
56
+ details.
@@ -0,0 +1,344 @@
1
+ # yamlock payload v2 design
2
+
3
+ Status: Phases A through C are implemented. V2 is the writer default; legacy
4
+ writing remains an explicit compatibility mode.
5
+
6
+ This document defines the security and compatibility contract for the current
7
+ yamlock payload format. It complements the usage-oriented README with the
8
+ canonical envelope, threat model, limits, and migration design.
9
+
10
+ ## Goals
11
+
12
+ - Authenticate encrypted values, field paths, and security-critical metadata.
13
+ - Derive encryption keys from user-provided secrets with a documented,
14
+ memory-hard KDF.
15
+ - Keep each encrypted value self-contained so `encryptValue` and
16
+ `decryptValue` remain useful without a surrounding file format.
17
+ - Read existing `yl|algorithm|salt|iv|data` values during migration.
18
+ - Reject malformed or resource-exhausting input before expensive work.
19
+ - Keep the format implementable with Node.js 22 built-ins and, later, Web
20
+ Crypto-compatible primitives for a local-only website demonstration.
21
+
22
+ ## Non-goals
23
+
24
+ - Key storage, key distribution, access control, or secret-manager replacement.
25
+ - Hiding field names, algorithms, KDF parameters, ciphertext length, or the
26
+ existence of encrypted values.
27
+ - In-place authentication of legacy AES-CBC values. They must be decrypted and
28
+ encrypted again to gain v2 protection.
29
+ - Accepting arbitrary OpenSSL ciphers or caller-selected key, nonce, and tag
30
+ lengths in v2.
31
+
32
+ ## Threat model
33
+
34
+ Payload v2 protects confidentiality and detects modification when an attacker
35
+ can read or edit a configuration file but does not know the yamlock secret. It
36
+ must reject a payload when an attacker changes its path, algorithm, KDF
37
+ metadata, salt, nonce, ciphertext, or authentication tag.
38
+
39
+ The format cannot protect plaintext after the application decrypts it, a secret
40
+ captured from the process environment, a compromised runtime, or a weak
41
+ passphrase recovered by offline guessing. The KDF raises the cost of guessing;
42
+ it does not make a weak passphrase strong.
43
+
44
+ ## Fixed v2 cryptographic profile
45
+
46
+ The first v2 writer uses one allowlisted profile:
47
+
48
+ | Property | Value |
49
+ | --- | --- |
50
+ | AEAD | `aes-256-gcm` |
51
+ | Key length | 32 bytes |
52
+ | Nonce length | 12 random bytes |
53
+ | Authentication tag | 16 bytes |
54
+ | KDF | `scrypt` |
55
+ | KDF salt | 16 random bytes |
56
+ | scrypt `N` | 32768 (`2^15`) |
57
+ | scrypt `r` | 8 |
58
+ | scrypt `p` | 1 |
59
+ | Derived length | 32 bytes |
60
+ | scrypt `maxmem` | at least 64 MiB; implementation target 128 MiB |
61
+
62
+ AES-GCM is chosen instead of ChaCha20-Poly1305 for the first profile because it
63
+ is available in Node.js 22 and Web Crypto, which reduces the chance that a
64
+ future browser-only demonstration grows a second, incompatible implementation.
65
+ The 12-byte nonce and 16-byte tag match the AEAD_AES_256_GCM profile described
66
+ by RFC 5116.
67
+
68
+ Each encryption generates a fresh KDF salt and nonce with
69
+ `crypto.randomBytes()`. A new salt produces a new derived key, while a random
70
+ nonce protects distinct invocations under that key. Neither value is secret.
71
+
72
+ The implementation remains sequential by default because unbounded parallel
73
+ scrypt calls could exhaust memory. A local Node.js 22.21.1 reference run on
74
+ Darwin arm64 on 2026-08-09 averaged 41.5 ms per encryption and 41.8 ms per
75
+ decryption across 20 values; encrypting a representative 20-value config took
76
+ 845 ms. The full hosted Ubuntu Node.js 22 suite exercises the fixed profile;
77
+ these local timings are a reference, not a portable performance guarantee.
78
+
79
+ ### Secret input encoding
80
+
81
+ - A JavaScript string is encoded once as UTF-8 bytes exactly as supplied.
82
+ - A `Buffer` is used as its exact byte sequence.
83
+ - Base64 and hexadecimal text is not decoded automatically. A value printed by
84
+ `yamlock keygen` is reused as the same text.
85
+ - Empty secrets are rejected before KDF execution.
86
+
87
+ This preserves a predictable CLI/API contract. A future explicit key-encoding
88
+ option requires a separate profile or versioned contract.
89
+
90
+ ## Serialized format
91
+
92
+ V2 is a pipe-delimited ASCII envelope:
93
+
94
+ ```text
95
+ yl|2|aes-256-gcm|scrypt|32768|8|1|<kdf_salt>|<nonce>|<path>|<ciphertext>|<tag>
96
+ ```
97
+
98
+ Binary segments use unpadded RFC 4648 base64url. The fields are:
99
+
100
+ 1. `yl`: yamlock marker.
101
+ 2. `2`: payload format version.
102
+ 3. `aes-256-gcm`: allowlisted AEAD identifier.
103
+ 4. `scrypt`: allowlisted KDF identifier.
104
+ 5. `32768`: scrypt `N` in canonical base-10 form.
105
+ 6. `8`: scrypt `r` in canonical base-10 form.
106
+ 7. `1`: scrypt `p` in canonical base-10 form.
107
+ 8. `kdf_salt`: exactly 16 decoded bytes.
108
+ 9. `nonce`: exactly 12 decoded bytes.
109
+ 10. `path`: the exact UTF-8 field path encoded as base64url.
110
+ 11. `ciphertext`: encrypted UTF-8 value; it may be empty for an empty input.
111
+ 12. `tag`: exactly 16 decoded bytes.
112
+
113
+ The field path remains visible after decoding, as it is in v1. It is metadata,
114
+ not a cryptographic salt. Naming in code and documentation must use
115
+ `fieldPath` and `kdfSalt` rather than calling both values `salt`.
116
+
117
+ ### Version detection
118
+
119
+ - A payload whose first two segments are `yl|2` is parsed only as v2.
120
+ - A payload beginning with `yl|` whose second segment is a known legacy cipher
121
+ is parsed as v1.
122
+ - A numeric version other than `2` is rejected as an unsupported format.
123
+ - A malformed v2 payload must never fall back to the legacy parser.
124
+
125
+ ## Additional authenticated data
126
+
127
+ The authenticated header is the ASCII prefix through the path segment:
128
+
129
+ ```text
130
+ yl|2|aes-256-gcm|scrypt|32768|8|1|<kdf_salt>|<nonce>|<path>
131
+ ```
132
+
133
+ During encryption, this exact byte sequence is passed to `cipher.setAAD()`
134
+ before plaintext processing.
135
+
136
+ During decryption, yamlock authenticates the exact serialized header, including
137
+ the stored path. Only after authentication succeeds does it compare the
138
+ authenticated path with the base64url encoding of the caller-provided field
139
+ path. Therefore:
140
+
141
+ - an unchanged payload at its original path authenticates and matches;
142
+ - moving the unchanged payload authenticates its stored metadata but fails the
143
+ caller-path comparison;
144
+ - editing the stored path changes AAD and fails authentication;
145
+ - editing any other header field either fails strict validation or changes the
146
+ derived key/AAD and fails authentication.
147
+
148
+ The caller-provided path is never trusted from the payload itself. Authentication
149
+ failure, a wrong key, a wrong path, and tampering should share a stable public
150
+ error code such as `ERR_AUTHENTICATION_FAILED`. Low-level OpenSSL errors must
151
+ not be exposed as a security distinction.
152
+
153
+ ## Encryption procedure
154
+
155
+ 1. Validate the plaintext type, secret, field path, profile, and size limits.
156
+ 2. Generate a 16-byte KDF salt and a 12-byte nonce.
157
+ 3. Derive a 32-byte key with the exact scrypt parameters stored in the header.
158
+ 4. Serialize the canonical authenticated header.
159
+ 5. Create `aes-256-gcm` with `authTagLength: 16`.
160
+ 6. Call `setAAD()` with the UTF-8 header bytes before `update()`.
161
+ 7. Encrypt the UTF-8 plaintext and obtain the 16-byte tag.
162
+ 8. Serialize ciphertext and tag as separate base64url fields.
163
+ 9. Discard references to derived key material as soon as practical.
164
+
165
+ ## Decryption procedure
166
+
167
+ 1. Apply the serialized-size cap before splitting or decoding.
168
+ 2. Require exactly 12 fields and canonical literal identifiers/numbers.
169
+ 3. Strictly validate base64url spelling, decoded lengths, UTF-8 path, and
170
+ ciphertext limits.
171
+ 4. Resolve KDF parameters only through an allowlist of supported profiles.
172
+ Never pass attacker-controlled unbounded values directly to scrypt.
173
+ 5. Derive the key from the supplied secret and parsed KDF salt.
174
+ 6. Reconstruct AAD from the exact validated serialized header.
175
+ 7. Create the GCM decipher with `authTagLength: 16`, then call `setAAD()` and
176
+ `setAuthTag()` before processing ciphertext.
177
+ 8. After `decipher.final()` authenticates successfully, compare the
178
+ authenticated stored path with the caller-provided path.
179
+ 9. Return plaintext only after both authentication and path comparison succeed.
180
+ 10. Convert all wrong-key, wrong-path, and tampering outcomes to the same public
181
+ authentication error.
182
+
183
+ No partially decrypted plaintext may be returned or written to disk.
184
+
185
+ ## Strict validation and resource limits
186
+
187
+ The first implementation should use explicit constants rather than accepting
188
+ arbitrary payload sizes:
189
+
190
+ - maximum serialized payload: 16 MiB;
191
+ - maximum decoded ciphertext: 8 MiB;
192
+ - maximum decoded field path: 4 KiB;
193
+ - KDF salt, nonce, and tag: exact profile lengths;
194
+ - KDF parameters: an allowlisted tuple, initially only `(32768, 8, 1)`;
195
+ - numeric fields: canonical decimal without sign, whitespace, exponent, or
196
+ leading zeroes;
197
+ - base64url: only `A-Z`, `a-z`, `0-9`, `-`, and `_`, with no `=` padding;
198
+ - decoded path: valid UTF-8 and non-empty.
199
+
200
+ These initial limits may become documented options later, but decryption must
201
+ always enforce safe hard ceilings before KDF execution.
202
+
203
+ ## API contract
204
+
205
+ V2 is the default writer:
206
+
207
+ ```js
208
+ encryptValue(value, key, fieldPath)
209
+ ```
210
+
211
+ For v2, arbitrary `algorithm`, `keyLength`, `ivLength`, and `authTagLength`
212
+ overrides are rejected. New secure combinations are added as reviewed profiles,
213
+ not as free-form OpenSSL options.
214
+
215
+ Legacy writing requires explicit compatibility options:
216
+
217
+ ```js
218
+ encryptValue(value, key, fieldPath, { formatVersion: 1 })
219
+ ```
220
+
221
+ Passing a legacy algorithm string or legacy algorithm options also remains an
222
+ explicit v1 request for API compatibility. The CLI requires `--legacy`; its
223
+ `--algorithm` option is rejected for encryption without that flag.
224
+
225
+ High-level configuration encryption is idempotent. Selected values that already
226
+ contain a valid yamlock payload are decrypted for key/path validation and then
227
+ preserved unchanged. `existingPayloadPolicy: 'error'` and CLI
228
+ `--error-on-encrypted` provide a strict failure mode. Encryption never upgrades
229
+ legacy payloads implicitly; callers use the migration workflow for that change.
230
+ The explicit `existingPayloadPolicy: 'encrypt'` / `--force-encrypt` escape hatch
231
+ treats a `yl|...` string as plaintext and may create nested encryption layers.
232
+
233
+ `decryptValue` detects v1/v2 from the payload. A caller must not need to supply
234
+ the algorithm for v2. `processConfig` propagates the format option and supports
235
+ mixed v1/v2 input during migration.
236
+
237
+ The synchronous public API can initially use `scryptSync` to avoid an immediate
238
+ breaking change. An async API may be added separately after measuring config
239
+ size and concurrency behavior; it must cap scrypt concurrency.
240
+
241
+ ## Legacy compatibility
242
+
243
+ Legacy payloads remain readable:
244
+
245
+ ```text
246
+ yl|<algorithm>|<base64_field_path>|<base64_iv>|<base64_data>
247
+ ```
248
+
249
+ Important limitations must be documented accurately:
250
+
251
+ - legacy AES-CBC payloads do not authenticate ciphertext or metadata;
252
+ - legacy field-path Base64 is a location check, not a KDF salt;
253
+ - legacy ChaCha20-Poly1305 authenticates ciphertext but does not authenticate
254
+ the serialized field path or algorithm metadata as v2 does;
255
+ - changing the text envelope cannot add authentication to legacy ciphertext.
256
+
257
+ Legacy writing stays available only as an explicit compatibility mode during a
258
+ defined transition. New v2 code must be isolated from legacy free-form cipher
259
+ options so insecure settings cannot leak into the new profile.
260
+
261
+ ## Migration and release sequence
262
+
263
+ Migration is decrypt-then-encrypt; it is never a header-only rewrite.
264
+
265
+ ### Phase A: dual reader, opt-in v2 writer
266
+
267
+ - Add frozen legacy fixtures before changing crypto utilities.
268
+ - Add v2 parser/formatter, KDF, AEAD, and tamper tests.
269
+ - Keep current encryption output unchanged unless `formatVersion: 2` is set.
270
+ - Make decryption auto-detect both formats.
271
+ - Document the security difference and the future default change.
272
+
273
+ ### Phase B: safe migration workflow
274
+
275
+ - [x] Add a CLI migration path with `--dry-run`, selective `--paths`, and a
276
+ separate output option.
277
+ - [x] Refuse double encryption and already-v2 values unless `--allow-mixed` is
278
+ explicitly requested; authenticated v2 values are then preserved unchanged.
279
+ - [x] Read the whole input, decrypt every selected legacy value, construct the
280
+ complete v2 result in memory, and only then replace the target atomically
281
+ while preserving file permissions.
282
+ - [x] Support mixed v1/v2 files and report counts without printing plaintext or
283
+ keys. Selected plaintext and non-string values fail closed.
284
+ - [x] Create an exclusive `<file>.yamlock.bak` for in-place migration unless
285
+ `--no-backup` is explicit. Separate output preserves the source and refuses
286
+ to replace an existing path.
287
+
288
+ Legacy AES-CBC values cannot be authenticated because their original format
289
+ does not contain an authentication tag. Migration validates their envelope,
290
+ field path, and successful decryption before wrapping the recovered value in
291
+ v2. Legacy authenticated ciphers and existing v2 values fail when integrity or
292
+ the key is wrong.
293
+
294
+ ### Phase C: v2 becomes the writer default
295
+
296
+ - [x] Switch the API and CLI writer defaults after local migration and installed
297
+ package smoke tests pass.
298
+ - [x] Keep explicit `formatVersion: 1` API compatibility and CLI `--legacy` for
299
+ a limited transition window.
300
+ - [x] Continue legacy reads so repositories can migrate incrementally.
301
+ - [x] Update README, changelog, examples, test fixtures, and key-rotation
302
+ guidance for the default change.
303
+ - [x] Confirm the default writer and migration suite in hosted Ubuntu CI.
304
+ - [ ] Perform the separately approved version, changelog finalization, tag, and
305
+ release steps.
306
+
307
+ ### Phase D: legacy write retirement
308
+
309
+ - Remove legacy writing only after a separately announced deprecation period.
310
+ - Retain legacy decryption unless a future major release deliberately drops it
311
+ with an external migration tool.
312
+
313
+ ## Required tests before implementation is considered complete
314
+
315
+ - Frozen v1 fixtures for every currently tested legacy algorithm.
316
+ - Deterministic v2 vectors with injected KDF salt and nonce at a low-level test
317
+ seam; production APIs must always generate them randomly.
318
+ - Round trips for empty strings, Unicode, arrays, and custom field paths.
319
+ - Rejection after single-field changes to version, algorithm, KDF, each KDF
320
+ parameter, salt, nonce, path, ciphertext, and tag.
321
+ - Wrong secret and wrong caller-provided path.
322
+ - Missing, extra, padded, non-canonical, invalid-UTF-8, oversized, and truncated
323
+ segments.
324
+ - Empty ciphertext with a valid tag and rejection of an empty tag.
325
+ - Mixed v1/v2 configuration traversal and selective paths.
326
+ - Migration all-or-nothing behavior, atomic writes, permission preservation,
327
+ dry-run, and absence of plaintext in stdout/stderr.
328
+ - npm tarball smoke test proving the installed package reads v1 and v2 without
329
+ falling back to repository `src` files.
330
+
331
+ ## Release status and limitations
332
+
333
+ - The fixed profile, migration paths, legacy fixtures, and full test suite pass
334
+ in hosted Ubuntu CI on Node.js 22.
335
+ - Stable public library error classes and codes are documented and tested.
336
+ - This design and implementation have not received a third-party security
337
+ audit; passing tests alone is not one.
338
+
339
+ ## References
340
+
341
+ - [Node.js 22 Crypto API](https://nodejs.org/docs/latest-v22.x/api/crypto.html)
342
+ - [NIST SP 800-38D: GCM and GMAC](https://csrc.nist.gov/pubs/sp/800/38/d/final)
343
+ - [RFC 5116: Authenticated Encryption](https://www.rfc-editor.org/rfc/rfc5116.html)
344
+ - [RFC 7914: scrypt](https://www.rfc-editor.org/rfc/rfc7914.html)
package/docs/errors.md ADDED
@@ -0,0 +1,71 @@
1
+ # Node.js error contract
2
+
3
+ yamlock exposes typed errors and stable `ERR_*` codes for expected validation,
4
+ payload, authentication, decryption, and config-processing failures. Callers
5
+ should branch on `error.code`; messages are written for people and may gain
6
+ clarifying detail without a major release.
7
+
8
+ ```js
9
+ import {
10
+ decryptValue,
11
+ YamlockAuthenticationError,
12
+ YamlockError
13
+ } from 'yamlock';
14
+
15
+ try {
16
+ decryptValue(payload, key, 'db.password');
17
+ } catch (error) {
18
+ if (error instanceof YamlockAuthenticationError) {
19
+ // A v2 key, path, or authenticated payload component did not match.
20
+ } else if (error instanceof YamlockError) {
21
+ console.error(error.code, error.message);
22
+ } else {
23
+ throw error;
24
+ }
25
+ }
26
+ ```
27
+
28
+ ## Classes
29
+
30
+ - `YamlockError` is the base class for expected public API failures. It extends
31
+ `Error` and exposes a stable `code` property.
32
+ - `YamlockValidationError` reports invalid values, keys, options, algorithms,
33
+ field paths, or path segments.
34
+ - `YamlockPayloadError` reports malformed, oversized, or unsupported payloads.
35
+ - `YamlockAuthenticationError` extends `YamlockPayloadError` and always uses
36
+ `ERR_AUTHENTICATION_FAILED`. V2 intentionally uses the same result for a
37
+ wrong key, wrong field path, or authenticated-data tampering.
38
+ - `YamlockDecryptionError` reports legacy decryption and legacy path-matching
39
+ failures. Legacy CBC payloads do not provide authenticated encryption.
40
+ - `YamlockConfigError` extends `YamlockValidationError` for `processConfig`
41
+ validation and traversal failures.
42
+
43
+ `YAMLOCK_ERROR_CODES` exports the supported names without requiring callers to
44
+ repeat string literals.
45
+
46
+ ## Common codes
47
+
48
+ | Code | Meaning |
49
+ | --- | --- |
50
+ | `ERR_INVALID_VALUE` | A direct API value has the wrong type. |
51
+ | `ERR_VALUE_TOO_LARGE` | Plaintext exceeds the supported v2 limit. |
52
+ | `ERR_INVALID_KEY` | The encryption key is empty or has an unsupported type. |
53
+ | `ERR_INVALID_FIELD_PATH` | The caller supplied an invalid field path. |
54
+ | `ERR_INVALID_OPTIONS` | Crypto options have an invalid shape or unsupported override. |
55
+ | `ERR_INVALID_MODE` | `processConfig` received an unknown mode. |
56
+ | `ERR_UNSUPPORTED_ALGORITHM` | The requested writer algorithm is unavailable or unsupported. |
57
+ | `ERR_UNSUPPORTED_PAYLOAD_VERSION` | The payload or requested writer version is unsupported. |
58
+ | `ERR_INVALID_PAYLOAD` | The payload is missing or malformed. |
59
+ | `ERR_PAYLOAD_TOO_LARGE` | A serialized payload or payload component exceeds its limit. |
60
+ | `ERR_UNSUPPORTED_PAYLOAD` | V2 metadata requests an unsupported algorithm, KDF, or KDF profile. |
61
+ | `ERR_AUTHENTICATION_FAILED` | V2 authentication failed; key, path, and tampering are intentionally indistinguishable. |
62
+ | `ERR_FIELD_PATH_MISMATCH` | A legacy payload stores a different field path. |
63
+ | `ERR_DECRYPTION_FAILED` | A legacy payload could not be decrypted. |
64
+
65
+ `processConfig` also preserves its specific codes for invalid roots/options,
66
+ policies, path serializers, path lists, circular input, path collisions,
67
+ non-string values, unsupported values, and already encrypted values. These are
68
+ available through `YAMLOCK_ERROR_CODES` and use `YamlockConfigError`.
69
+
70
+ The CLI uses the same library codes when available and keeps its existing
71
+ `[yamlock:ERR_*]` output format and exit status `1` for failures.
@@ -0,0 +1,51 @@
1
+ # YAML rewrite behavior
2
+
3
+ yamlock reads YAML through `js-yaml`, processes the resulting JavaScript value,
4
+ and serializes that value back to YAML. It does not edit or round-trip the
5
+ original YAML syntax tree. A write can therefore preserve data while changing
6
+ or removing presentation details.
7
+
8
+ ## What happens during a rewrite
9
+
10
+ | YAML feature | Current behavior |
11
+ | --- | --- |
12
+ | Full-line and inline comments | Removed. |
13
+ | Indentation, blank lines, flow collections, and quoting | Normalized by `yaml.dump`; original choices are not retained. |
14
+ | Literal and folded block scalar styles | May be changed when the parsed string is emitted again. |
15
+ | Anchors and aliases | Parsed as shared JavaScript references, then expanded into independent branches by `processConfig`; anchor names and alias syntax are removed. |
16
+ | Merge keys (`<<`) | Resolved during parsing and emitted as ordinary mapping entries. |
17
+ | Standard explicit tags such as `!!str` | Parsed to a JavaScript value and usually emitted without the original explicit tag. |
18
+ | Unknown custom tags | Rejected during input parsing; the source file is not written. |
19
+ | Mapping order | Usually follows JavaScript property enumeration, but byte-for-byte order preservation is not a contract. |
20
+
21
+ Anchored or merged values are processed by their resolved field paths. If the
22
+ same source value appears at `defaults.token` and `service.token`, those are two
23
+ independent authenticated paths. Selecting one path does not implicitly select
24
+ the other.
25
+
26
+ ## When the original bytes are preserved
27
+
28
+ - `--dry-run` never writes the source file.
29
+ - An in-place `encrypt` that finds no plaintext value to change returns without
30
+ rewriting the file.
31
+ - A failed parse, validation, encryption, decryption, or write does not replace
32
+ the source file.
33
+
34
+ A separate `--output` is always serialized as a new YAML document when the
35
+ operation succeeds, so its presentation may differ even when most values are
36
+ unchanged.
37
+
38
+ ## Recommended workflow
39
+
40
+ 1. Keep source YAML under version control or make a verified backup.
41
+ 2. Run with `--dry-run` to inspect the complete serialized result.
42
+ 3. Use `--paths` to select every resolved path that should be encrypted,
43
+ including values originally introduced through aliases or merge keys.
44
+ 4. Use `--output` when the source document's comments or formatting must remain
45
+ untouched.
46
+ 5. Do not use unknown application-specific YAML tags in files processed by the
47
+ current CLI.
48
+
49
+ Preserving comments, custom tags, anchor identities, and exact formatting would
50
+ require a syntax-tree-aware YAML editing layer and is not part of the current
51
+ release contract.
@@ -0,0 +1,31 @@
1
+ import { encryptValue, decryptValue, processConfig } from 'yamlock';
2
+
3
+ const KEY = process.env.YAMLOCK_KEY || 'dev-secret-key';
4
+
5
+ function simpleValueDemo() {
6
+ console.log('--- encryptValue / decryptValue ---');
7
+ // New values use the authenticated v2 format by default.
8
+ const payload = encryptValue('swordfish', KEY, 'db.password');
9
+ console.log('Encrypted payload:', payload);
10
+ const original = decryptValue(payload, KEY, 'db.password');
11
+ console.log('Decrypted value:', original);
12
+ }
13
+
14
+ function configDemo() {
15
+ console.log('\n--- processConfig ---');
16
+ const config = {
17
+ db: {
18
+ user: 'app',
19
+ password: 'swordfish'
20
+ }
21
+ };
22
+
23
+ const encrypted = processConfig(config, { mode: 'encrypt', key: KEY });
24
+ console.log('Encrypted config:', encrypted);
25
+
26
+ const decrypted = processConfig(encrypted, { mode: 'decrypt', key: KEY });
27
+ console.log('Decrypted config:', decrypted);
28
+ }
29
+
30
+ simpleValueDemo();
31
+ configDemo();