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 +223 -38
- package/bin/yamlock +8 -0
- package/dist/cli/cli.js +457 -74
- package/dist/crypto/decrypt.js +112 -13
- package/dist/crypto/encrypt.js +107 -18
- package/dist/crypto/payload-v2.js +371 -0
- package/dist/crypto/utils.js +65 -14
- package/dist/errors.js +59 -0
- package/dist/index.d.ts +123 -0
- package/dist/index.js +10 -0
- package/dist/utils/config.js +294 -54
- package/dist/utils/file.js +58 -0
- package/dist/utils/migrate.js +170 -0
- package/dist/utils/path.js +58 -9
- package/docs/api.md +56 -0
- package/docs/design/payload-v2.md +344 -0
- package/docs/errors.md +71 -0
- package/docs/yaml-behavior.md +51 -0
- package/examples/basic.js +31 -0
- package/examples/docs/ci-cd.md +58 -0
- package/examples/docs/key-rotation.md +65 -0
- package/package.json +19 -7
package/dist/utils/path.js
CHANGED
|
@@ -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
|
|
3
|
-
*
|
|
4
|
-
* Example: ["db", "users", 0
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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();
|