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 +249 -36
- 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 +298 -44
- 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/cli/cli.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
lstatSync,
|
|
4
|
+
readFileSync
|
|
5
|
+
} from 'node:fs';
|
|
6
|
+
import { extname, resolve } from 'node:path';
|
|
4
7
|
import { exit } from 'node:process';
|
|
5
8
|
import { createRequire } from 'node:module';
|
|
6
9
|
import { randomBytes } from 'node:crypto';
|
|
10
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
7
11
|
|
|
8
12
|
import yaml from 'js-yaml';
|
|
9
13
|
|
|
10
14
|
import { processConfig } from '../utils/config.js';
|
|
15
|
+
import { writeFileAtomically } from '../utils/file.js';
|
|
16
|
+
import { migrateConfig } from '../utils/migrate.js';
|
|
11
17
|
import { listSupportedAlgorithms, TESTED_ALGORITHMS } from '../crypto/utils.js';
|
|
12
18
|
|
|
13
19
|
const require = createRequire(import.meta.url);
|
|
@@ -18,6 +24,48 @@ const BANNER = `
|
|
|
18
24
|
░░█░░█▀█░█░▀░█░█░░░█░█░█░░░█▀▄░
|
|
19
25
|
░░▀░░▀░▀░▀░░░▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░`;
|
|
20
26
|
|
|
27
|
+
const KEYGEN_MIN_LENGTH = 1;
|
|
28
|
+
const KEYGEN_MAX_LENGTH = 4096;
|
|
29
|
+
const FILE_COMMANDS = new Set(['encrypt', 'decrypt', 'migrate']);
|
|
30
|
+
const OPTION_SPECS = [
|
|
31
|
+
{ key: 'key', names: ['-k', '--key'], takesValue: true },
|
|
32
|
+
{ key: 'algorithm', names: ['-a', '--algorithm'], takesValue: true },
|
|
33
|
+
{ key: 'output', names: ['-o', '--output'], takesValue: true },
|
|
34
|
+
{ key: 'paths', names: ['-p', '--paths'], takesValue: true },
|
|
35
|
+
{ key: 'dryRun', names: ['-d', '--dry-run'], takesValue: false },
|
|
36
|
+
{ key: 'allowMixed', names: ['--allow-mixed'], takesValue: false },
|
|
37
|
+
{ key: 'noBackup', names: ['--no-backup'], takesValue: false },
|
|
38
|
+
{ key: 'legacy', names: ['--legacy'], takesValue: false },
|
|
39
|
+
{ key: 'errorOnEncrypted', names: ['--error-on-encrypted'], takesValue: false },
|
|
40
|
+
{ key: 'forceEncrypt', names: ['--force-encrypt'], takesValue: false },
|
|
41
|
+
{ key: 'length', names: ['--length'], takesValue: true },
|
|
42
|
+
{ key: 'format', names: ['--format'], takesValue: true }
|
|
43
|
+
];
|
|
44
|
+
const OPTION_BY_NAME = new Map(
|
|
45
|
+
OPTION_SPECS.flatMap((spec) => spec.names.map((name) => [name, spec]))
|
|
46
|
+
);
|
|
47
|
+
const OPTION_LABELS = new Map(
|
|
48
|
+
OPTION_SPECS.map((spec) => [spec.key, spec.names.at(-1)])
|
|
49
|
+
);
|
|
50
|
+
const COMMAND_OPTIONS = new Map([
|
|
51
|
+
['encrypt', new Set([
|
|
52
|
+
'key',
|
|
53
|
+
'algorithm',
|
|
54
|
+
'output',
|
|
55
|
+
'paths',
|
|
56
|
+
'dryRun',
|
|
57
|
+
'legacy',
|
|
58
|
+
'errorOnEncrypted',
|
|
59
|
+
'forceEncrypt'
|
|
60
|
+
])],
|
|
61
|
+
['decrypt', new Set(['key', 'output', 'paths', 'dryRun'])],
|
|
62
|
+
['migrate', new Set(['key', 'output', 'paths', 'dryRun', 'allowMixed', 'noBackup'])],
|
|
63
|
+
['keygen', new Set(['length', 'format'])],
|
|
64
|
+
['help', new Set()],
|
|
65
|
+
['version', new Set()],
|
|
66
|
+
['algorithms', new Set()]
|
|
67
|
+
]);
|
|
68
|
+
|
|
21
69
|
function getHelpText() {
|
|
22
70
|
return `${BANNER}
|
|
23
71
|
Version: ${packageJson.version}
|
|
@@ -28,18 +76,34 @@ yamlock <command> [options]
|
|
|
28
76
|
Commands:
|
|
29
77
|
encrypt <file> Encrypt string values in the given YAML/JSON file.
|
|
30
78
|
decrypt <file> Decrypt string values in the given YAML/JSON file.
|
|
79
|
+
migrate <file> Migrate selected legacy payloads to authenticated v2.
|
|
80
|
+
help Show this help text.
|
|
31
81
|
version Print the yamlock CLI version.
|
|
32
82
|
algorithms Print the list of supported cipher algorithms.
|
|
33
83
|
keygen Generate a random YAMLOCK_KEY.
|
|
34
84
|
|
|
35
85
|
Options:
|
|
36
86
|
-k, --key <value> Encryption key (or use YAMLOCK_KEY env).
|
|
37
|
-
-a, --algorithm <value>
|
|
87
|
+
-a, --algorithm <value> Legacy cipher algorithm (encrypt --legacy only).
|
|
38
88
|
-o, --output <file> Write the result to a different file (otherwise overwrites the input file).
|
|
39
|
-
-p, --paths <p1,p2> Comma-separated
|
|
40
|
-
-d, --dry-run
|
|
41
|
-
--
|
|
89
|
+
-p, --paths <p1,p2> Comma-separated escaped field paths to process (dot/bracket notation).
|
|
90
|
+
-d, --dry-run Preview the operation without modifying files.
|
|
91
|
+
--allow-mixed (migrate) Authenticate and preserve selected v2 values.
|
|
92
|
+
--no-backup (migrate) Replace the input without creating <file>.yamlock.bak.
|
|
93
|
+
--legacy (encrypt) Write the legacy v1 format for compatibility.
|
|
94
|
+
--error-on-encrypted (encrypt) Fail if a selected value is already encrypted.
|
|
95
|
+
--force-encrypt (encrypt) Encrypt selected yl|... strings as plaintext.
|
|
96
|
+
--length <bytes> (keygen) Random bytes to generate, 1-${KEYGEN_MAX_LENGTH} (default: 32).
|
|
42
97
|
--format <hex|base64> (keygen) Output format (default: base64).
|
|
98
|
+
-h, --help Show this help text.
|
|
99
|
+
|
|
100
|
+
YAML rewrite note:
|
|
101
|
+
Comments, anchors/aliases, explicit tags, quoting, and formatting are not
|
|
102
|
+
preserved byte-for-byte. Use --dry-run or --output before replacing a file.
|
|
103
|
+
|
|
104
|
+
Path syntax:
|
|
105
|
+
Object-key backslashes, dots, brackets, and commas must be backslash-escaped.
|
|
106
|
+
Example: db\\.primary.token selects { "db.primary": { "token": ... } }.
|
|
43
107
|
`;
|
|
44
108
|
}
|
|
45
109
|
|
|
@@ -61,14 +125,35 @@ function detectFormat(filePath) {
|
|
|
61
125
|
}
|
|
62
126
|
|
|
63
127
|
function readConfigFile(filePath) {
|
|
128
|
+
const stat = lstatSync(filePath);
|
|
64
129
|
const content = readFileSync(filePath, 'utf8');
|
|
65
130
|
const format = detectFormat(filePath);
|
|
66
131
|
|
|
67
132
|
if (format === 'yaml') {
|
|
68
|
-
return { format, data: yaml.load(content) ?? {}, raw: content };
|
|
133
|
+
return { format, data: yaml.load(content) ?? {}, raw: content, stat };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { format, data: JSON.parse(content), raw: content, stat };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function describeReadFailure(error) {
|
|
140
|
+
if (typeof error?.reason === 'string') {
|
|
141
|
+
const line = Number.isInteger(error.mark?.line) ? error.mark.line + 1 : null;
|
|
142
|
+
const column = Number.isInteger(error.mark?.column) ? error.mark.column + 1 : null;
|
|
143
|
+
const location = line && column ? ` at line ${line}, column ${column}` : '';
|
|
144
|
+
return `Invalid YAML: ${error.reason}${location}.`;
|
|
69
145
|
}
|
|
70
146
|
|
|
71
|
-
|
|
147
|
+
if (error instanceof SyntaxError) {
|
|
148
|
+
return 'Invalid JSON syntax.';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const fileErrors = {
|
|
152
|
+
EACCES: 'Input file is not readable.',
|
|
153
|
+
EISDIR: 'Input path is a directory, not a file.',
|
|
154
|
+
ENOENT: 'Input file does not exist.'
|
|
155
|
+
};
|
|
156
|
+
return fileErrors[error?.code] ?? 'Unable to read or parse the input file.';
|
|
72
157
|
}
|
|
73
158
|
|
|
74
159
|
function serializeConfig(format, data) {
|
|
@@ -79,75 +164,159 @@ function serializeConfig(format, data) {
|
|
|
79
164
|
return `${JSON.stringify(data, null, 2)}\n`;
|
|
80
165
|
}
|
|
81
166
|
|
|
82
|
-
function writeConfigFile(filePath, serialized) {
|
|
83
|
-
writeFileSync(filePath, serialized, 'utf8');
|
|
84
|
-
}
|
|
85
|
-
|
|
86
167
|
function parsePaths(value) {
|
|
87
168
|
if (!value) {
|
|
88
169
|
return [];
|
|
89
170
|
}
|
|
90
171
|
|
|
91
|
-
|
|
92
|
-
|
|
172
|
+
const paths = [];
|
|
173
|
+
let current = '';
|
|
174
|
+
let escaped = false;
|
|
175
|
+
|
|
176
|
+
for (const character of String(value)) {
|
|
177
|
+
if (character === ',' && !escaped) {
|
|
178
|
+
paths.push(current);
|
|
179
|
+
current = '';
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
current += character;
|
|
184
|
+
if (escaped) {
|
|
185
|
+
escaped = false;
|
|
186
|
+
} else if (character === '\\') {
|
|
187
|
+
escaped = true;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
paths.push(current);
|
|
191
|
+
|
|
192
|
+
return paths
|
|
93
193
|
.map((segment) => segment.trim())
|
|
94
194
|
.filter((segment) => segment.length > 0);
|
|
95
195
|
}
|
|
96
196
|
|
|
97
197
|
function parseArgs(argv) {
|
|
98
198
|
const args = argv.slice(2);
|
|
199
|
+
const commandAliases = new Map([
|
|
200
|
+
['-h', 'help'],
|
|
201
|
+
['--help', 'help']
|
|
202
|
+
]);
|
|
99
203
|
const result = {
|
|
100
|
-
command: args[0],
|
|
204
|
+
command: commandAliases.get(args[0]) ?? args[0],
|
|
101
205
|
file: undefined,
|
|
102
|
-
options: {
|
|
206
|
+
options: {
|
|
207
|
+
dryRun: false,
|
|
208
|
+
allowMixed: false,
|
|
209
|
+
noBackup: false,
|
|
210
|
+
legacy: false,
|
|
211
|
+
errorOnEncrypted: false,
|
|
212
|
+
forceEncrypt: false
|
|
213
|
+
},
|
|
214
|
+
specifiedOptions: new Set()
|
|
103
215
|
};
|
|
104
216
|
|
|
105
|
-
let
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
217
|
+
for (let i = 1; i < args.length; i += 1) {
|
|
218
|
+
const arg = args[i];
|
|
219
|
+
const spec = OPTION_BY_NAME.get(arg);
|
|
220
|
+
|
|
221
|
+
if (spec) {
|
|
222
|
+
if (result.specifiedOptions.has(spec.key)) {
|
|
223
|
+
throw cliError('ERR_DUPLICATE_OPTION', `Option ${OPTION_LABELS.get(spec.key)} was provided more than once.`);
|
|
224
|
+
}
|
|
225
|
+
result.specifiedOptions.add(spec.key);
|
|
226
|
+
|
|
227
|
+
if (!spec.takesValue) {
|
|
228
|
+
result.options[spec.key] = true;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const next = args[i + 1];
|
|
233
|
+
const negativeLength = spec.key === 'length' && /^-\d/.test(next ?? '');
|
|
234
|
+
if (
|
|
235
|
+
next === undefined ||
|
|
236
|
+
next === '' ||
|
|
237
|
+
(next.startsWith('-') && !negativeLength)
|
|
238
|
+
) {
|
|
239
|
+
throw cliError(
|
|
240
|
+
'ERR_MISSING_OPTION_VALUE',
|
|
241
|
+
`Option ${OPTION_LABELS.get(spec.key)} requires a value.`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (spec.key === 'paths') {
|
|
246
|
+
const paths = parsePaths(next);
|
|
247
|
+
if (paths.length === 0) {
|
|
248
|
+
throw cliError(
|
|
249
|
+
'ERR_INVALID_OPTION_VALUE',
|
|
250
|
+
'Option --paths requires at least one non-empty field path.'
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
result.options.paths = paths;
|
|
254
|
+
} else {
|
|
255
|
+
result.options[spec.key] = next;
|
|
256
|
+
}
|
|
257
|
+
i += 1;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (arg.startsWith('-')) {
|
|
262
|
+
throw cliError('ERR_UNKNOWN_OPTION', `Unknown option: ${arg}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (result.file !== undefined) {
|
|
266
|
+
throw cliError('ERR_UNEXPECTED_ARGUMENT', `Unexpected argument: ${arg}`);
|
|
267
|
+
}
|
|
268
|
+
result.file = arg;
|
|
112
269
|
}
|
|
113
270
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const next = args[i + 1];
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
117
273
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
} else if (arg === '--length') {
|
|
131
|
-
result.options.length = next;
|
|
132
|
-
i += 1;
|
|
133
|
-
} else if (arg === '--format') {
|
|
134
|
-
result.options.format = next;
|
|
135
|
-
i += 1;
|
|
136
|
-
} else if (arg === '-d' || arg === '--dry-run') {
|
|
137
|
-
result.options.dryRun = true;
|
|
274
|
+
function validateCommandLine({ command, file, specifiedOptions }) {
|
|
275
|
+
const allowedOptions = COMMAND_OPTIONS.get(command);
|
|
276
|
+
if (!allowedOptions) {
|
|
277
|
+
throw cliError('ERR_UNKNOWN_COMMAND', `Unknown command: ${command}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
for (const option of specifiedOptions) {
|
|
281
|
+
if (!allowedOptions.has(option)) {
|
|
282
|
+
throw cliError(
|
|
283
|
+
'ERR_INVALID_OPTION',
|
|
284
|
+
`${command} does not accept ${OPTION_LABELS.get(option)}.`
|
|
285
|
+
);
|
|
138
286
|
}
|
|
139
287
|
}
|
|
140
288
|
|
|
141
|
-
if (
|
|
142
|
-
|
|
289
|
+
if (!FILE_COMMANDS.has(command) && file !== undefined) {
|
|
290
|
+
throw cliError('ERR_UNEXPECTED_ARGUMENT', `${command} does not accept a file argument.`);
|
|
143
291
|
}
|
|
292
|
+
}
|
|
144
293
|
|
|
145
|
-
|
|
294
|
+
function parseKeyLength(value) {
|
|
295
|
+
const rawValue = String(value);
|
|
296
|
+
if (!/^\d+$/.test(rawValue)) {
|
|
297
|
+
throw cliError(
|
|
298
|
+
'ERR_INVALID_LENGTH',
|
|
299
|
+
`Key length must be an integer between ${KEYGEN_MIN_LENGTH} and ${KEYGEN_MAX_LENGTH} bytes.`
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const length = Number(rawValue);
|
|
304
|
+
if (
|
|
305
|
+
!Number.isSafeInteger(length) ||
|
|
306
|
+
length < KEYGEN_MIN_LENGTH ||
|
|
307
|
+
length > KEYGEN_MAX_LENGTH
|
|
308
|
+
) {
|
|
309
|
+
throw cliError(
|
|
310
|
+
'ERR_INVALID_LENGTH',
|
|
311
|
+
`Key length must be an integer between ${KEYGEN_MIN_LENGTH} and ${KEYGEN_MAX_LENGTH} bytes.`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return length;
|
|
146
316
|
}
|
|
147
317
|
|
|
148
318
|
function generateRandomKey(length, format) {
|
|
149
|
-
const
|
|
150
|
-
const buffer = randomBytes(size);
|
|
319
|
+
const buffer = randomBytes(length);
|
|
151
320
|
if (format === 'hex') {
|
|
152
321
|
return buffer.toString('hex');
|
|
153
322
|
}
|
|
@@ -159,31 +328,201 @@ function fail(code, message) {
|
|
|
159
328
|
return exit(1);
|
|
160
329
|
}
|
|
161
330
|
|
|
162
|
-
function
|
|
163
|
-
|
|
331
|
+
function cliError(code, message) {
|
|
332
|
+
return Object.assign(new Error(message), { code });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function validateOperationSource(filePath, config) {
|
|
336
|
+
if (config.stat.isSymbolicLink() || !config.stat.isFile()) {
|
|
337
|
+
throw cliError('ERR_UNSAFE_INPUT', 'Input must be a regular file, not a symbolic link.');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let currentStat;
|
|
341
|
+
let currentRaw;
|
|
342
|
+
try {
|
|
343
|
+
currentStat = lstatSync(filePath);
|
|
344
|
+
currentRaw = readFileSync(filePath, 'utf8');
|
|
345
|
+
} catch {
|
|
346
|
+
throw cliError('ERR_INPUT_CHANGED', 'Input changed after it was read; no file was replaced.');
|
|
347
|
+
}
|
|
348
|
+
if (
|
|
349
|
+
!currentStat.isFile() ||
|
|
350
|
+
currentStat.isSymbolicLink() ||
|
|
351
|
+
currentStat.dev !== config.stat.dev ||
|
|
352
|
+
currentStat.ino !== config.stat.ino ||
|
|
353
|
+
currentStat.mode !== config.stat.mode ||
|
|
354
|
+
currentRaw !== config.raw
|
|
355
|
+
) {
|
|
356
|
+
throw cliError('ERR_INPUT_CHANGED', 'Input changed after it was read; no file was replaced.');
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function resolveOutputMode(outputPath, sourceMode) {
|
|
361
|
+
try {
|
|
362
|
+
const outputStat = lstatSync(outputPath);
|
|
363
|
+
if (outputStat.isSymbolicLink() || !outputStat.isFile()) {
|
|
364
|
+
throw cliError('ERR_UNSAFE_OUTPUT', 'Output must be a regular file, not a symbolic link.');
|
|
365
|
+
}
|
|
366
|
+
return outputStat.mode & 0o7777;
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if (error.code === 'ENOENT') {
|
|
369
|
+
return sourceMode;
|
|
370
|
+
}
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function handleWrite({ dryRun, file, absolutePath, outputPath, config, data, operation }) {
|
|
376
|
+
const serialized = serializeConfig(config.format, data);
|
|
164
377
|
if (dryRun) {
|
|
165
378
|
print(`DRY-RUN (${operation}) ${file}`);
|
|
166
379
|
print('--- original');
|
|
167
|
-
print((
|
|
380
|
+
print((config.raw ?? '').trimEnd());
|
|
168
381
|
print('+++ result');
|
|
169
382
|
print(serialized.trimEnd());
|
|
170
|
-
if (outputPath !==
|
|
383
|
+
if (outputPath !== absolutePath) {
|
|
171
384
|
print(`(would write to ${outputPath})`);
|
|
172
385
|
}
|
|
173
386
|
print('No files were modified.');
|
|
174
387
|
return;
|
|
175
388
|
}
|
|
176
389
|
|
|
177
|
-
|
|
390
|
+
const sourceMode = config.stat.mode & 0o7777;
|
|
391
|
+
const inPlace = outputPath === absolutePath;
|
|
392
|
+
validateOperationSource(absolutePath, config);
|
|
393
|
+
const outputMode = inPlace
|
|
394
|
+
? sourceMode
|
|
395
|
+
: resolveOutputMode(outputPath, sourceMode);
|
|
396
|
+
|
|
397
|
+
writeFileAtomically(outputPath, serialized, { mode: outputMode });
|
|
178
398
|
print(`${operation === 'encrypt' ? 'Encrypted' : 'Decrypted'} values in ${outputPath}`);
|
|
179
399
|
}
|
|
180
400
|
|
|
401
|
+
function validateMigrationSource(filePath, config) {
|
|
402
|
+
if (config.stat.isSymbolicLink()) {
|
|
403
|
+
throw cliError(
|
|
404
|
+
'ERR_MIGRATION_UNSAFE_INPUT',
|
|
405
|
+
'Migration input must not be a symbolic link.'
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (!config.stat.isFile()) {
|
|
410
|
+
throw cliError(
|
|
411
|
+
'ERR_MIGRATION_UNSAFE_INPUT',
|
|
412
|
+
'Migration input must be a regular file.'
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const currentStat = lstatSync(filePath);
|
|
417
|
+
const currentRaw = readFileSync(filePath, 'utf8');
|
|
418
|
+
if (
|
|
419
|
+
!currentStat.isFile() ||
|
|
420
|
+
currentStat.isSymbolicLink() ||
|
|
421
|
+
currentStat.dev !== config.stat.dev ||
|
|
422
|
+
currentStat.ino !== config.stat.ino ||
|
|
423
|
+
currentStat.mode !== config.stat.mode ||
|
|
424
|
+
currentRaw !== config.raw
|
|
425
|
+
) {
|
|
426
|
+
throw cliError(
|
|
427
|
+
'ERR_MIGRATION_INPUT_CHANGED',
|
|
428
|
+
'Migration input changed after it was read.'
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function handleMigration({ file, absolutePath, outputPath, config, key, options }) {
|
|
434
|
+
validateMigrationSource(absolutePath, config);
|
|
435
|
+
const result = migrateConfig(config.data, {
|
|
436
|
+
key,
|
|
437
|
+
paths: options.paths,
|
|
438
|
+
allowMixed: options.allowMixed
|
|
439
|
+
});
|
|
440
|
+
const serialized = serializeConfig(config.format, result.data);
|
|
441
|
+
|
|
442
|
+
if (!result.changed) {
|
|
443
|
+
print(`No legacy values required migration; authenticated ${result.stats.preservedV2} v2 value(s).`);
|
|
444
|
+
print('No files were modified.');
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (options.dryRun) {
|
|
449
|
+
print(`DRY-RUN (migrate) ${file}`);
|
|
450
|
+
print(`Would migrate ${result.stats.migrated} legacy value(s) and preserve ${result.stats.preservedV2} v2 value(s).`);
|
|
451
|
+
if (outputPath === absolutePath && !options.noBackup) {
|
|
452
|
+
print(`Would create backup ${absolutePath}.yamlock.bak.`);
|
|
453
|
+
}
|
|
454
|
+
print(`Would write to ${outputPath}.`);
|
|
455
|
+
print('No files were modified.');
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
validateMigrationSource(absolutePath, config);
|
|
460
|
+
const inPlace = outputPath === absolutePath;
|
|
461
|
+
let backupPath;
|
|
462
|
+
if (inPlace && !options.noBackup) {
|
|
463
|
+
backupPath = `${absolutePath}.yamlock.bak`;
|
|
464
|
+
try {
|
|
465
|
+
writeFileAtomically(backupPath, config.raw, {
|
|
466
|
+
mode: config.stat.mode & 0o7777,
|
|
467
|
+
refuseExisting: true
|
|
468
|
+
});
|
|
469
|
+
} catch (error) {
|
|
470
|
+
if (error.code === 'EEXIST') {
|
|
471
|
+
throw cliError(
|
|
472
|
+
'ERR_MIGRATION_BACKUP_EXISTS',
|
|
473
|
+
`Backup already exists: ${backupPath}`
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
try {
|
|
481
|
+
writeFileAtomically(outputPath, serialized, {
|
|
482
|
+
mode: config.stat.mode & 0o7777,
|
|
483
|
+
refuseExisting: !inPlace
|
|
484
|
+
});
|
|
485
|
+
} catch (error) {
|
|
486
|
+
if (!inPlace && error.code === 'EEXIST') {
|
|
487
|
+
throw cliError(
|
|
488
|
+
'ERR_MIGRATION_OUTPUT_EXISTS',
|
|
489
|
+
`Migration output already exists: ${outputPath}`
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
throw error;
|
|
493
|
+
}
|
|
494
|
+
print(`Migrated ${result.stats.migrated} legacy value(s) to v2 in ${outputPath}.`);
|
|
495
|
+
if (result.stats.preservedV2 > 0) {
|
|
496
|
+
print(`Authenticated and preserved ${result.stats.preservedV2} existing v2 value(s).`);
|
|
497
|
+
}
|
|
498
|
+
if (backupPath) {
|
|
499
|
+
print(`Backup: ${backupPath}`);
|
|
500
|
+
} else if (inPlace) {
|
|
501
|
+
print('Backup disabled by --no-backup.');
|
|
502
|
+
} else {
|
|
503
|
+
print('Source file was preserved; no backup was created.');
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
181
507
|
export async function runCli(argv = process.argv) {
|
|
182
|
-
|
|
508
|
+
let parsed;
|
|
509
|
+
try {
|
|
510
|
+
parsed = parseArgs(argv);
|
|
511
|
+
if (parsed.command) {
|
|
512
|
+
validateCommandLine(parsed);
|
|
513
|
+
}
|
|
514
|
+
} catch (error) {
|
|
515
|
+
const code = typeof error.code === 'string' && error.code.startsWith('ERR_')
|
|
516
|
+
? error.code
|
|
517
|
+
: 'ERR_INVALID_ARGUMENTS';
|
|
518
|
+
return fail(code, error.message);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const { command, file, options } = parsed;
|
|
183
522
|
|
|
184
|
-
if (!command) {
|
|
523
|
+
if (!command || command === 'help') {
|
|
185
524
|
print(getHelpText().trim());
|
|
186
|
-
return exit(
|
|
525
|
+
return exit(0);
|
|
187
526
|
}
|
|
188
527
|
|
|
189
528
|
if (command === 'version') {
|
|
@@ -197,31 +536,33 @@ export async function runCli(argv = process.argv) {
|
|
|
197
536
|
const testedSet = new Set(tested);
|
|
198
537
|
const additional = algorithms.filter((name) => !testedSet.has(name));
|
|
199
538
|
|
|
200
|
-
print('
|
|
539
|
+
print('Default v2 profile: aes-256-gcm with scrypt.');
|
|
540
|
+
print('\nTested legacy algorithms (covered by yamlock fixtures):');
|
|
201
541
|
tested.forEach((name) => print(`- ${name}`));
|
|
202
542
|
|
|
203
543
|
if (additional.length > 0) {
|
|
204
544
|
print('\nAdditional algorithms available in this runtime:');
|
|
205
545
|
additional.forEach((name) => print(`- ${name}`));
|
|
206
|
-
print('\
|
|
546
|
+
print('\nAdditional ciphers require explicit legacy mode and are not part of the official test matrix.');
|
|
207
547
|
}
|
|
208
548
|
return exit(0);
|
|
209
549
|
}
|
|
210
550
|
|
|
211
551
|
if (command === 'keygen') {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
return fail('ERR_INVALID_LENGTH',
|
|
552
|
+
let desiredLength;
|
|
553
|
+
try {
|
|
554
|
+
desiredLength = options.length === undefined ? 32 : parseKeyLength(options.length);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
return fail(error.code ?? 'ERR_INVALID_LENGTH', error.message);
|
|
217
557
|
}
|
|
558
|
+
const normalizedFormat = (options.format ?? 'base64').toLowerCase();
|
|
218
559
|
|
|
219
560
|
if (!['base64', 'hex'].includes(normalizedFormat)) {
|
|
220
561
|
return fail('ERR_INVALID_FORMAT', 'Key format must be either "base64" or "hex".');
|
|
221
562
|
}
|
|
222
563
|
|
|
223
564
|
const keyValue = generateRandomKey(desiredLength, normalizedFormat);
|
|
224
|
-
print(`Generated key (${normalizedFormat}, ${
|
|
565
|
+
print(`Generated key (${normalizedFormat}, ${desiredLength} bytes of entropy):`);
|
|
225
566
|
print(keyValue);
|
|
226
567
|
print('\nStore it securely, e.g.');
|
|
227
568
|
print(` export YAMLOCK_KEY="${keyValue}"`);
|
|
@@ -244,7 +585,7 @@ export async function runCli(argv = process.argv) {
|
|
|
244
585
|
try {
|
|
245
586
|
config = readConfigFile(absolutePath);
|
|
246
587
|
} catch (error) {
|
|
247
|
-
return fail('ERR_READ_FAILED',
|
|
588
|
+
return fail('ERR_READ_FAILED', describeReadFailure(error));
|
|
248
589
|
}
|
|
249
590
|
|
|
250
591
|
const outputPath = options.output
|
|
@@ -252,19 +593,54 @@ export async function runCli(argv = process.argv) {
|
|
|
252
593
|
: absolutePath;
|
|
253
594
|
|
|
254
595
|
try {
|
|
596
|
+
if (command === 'migrate') {
|
|
597
|
+
handleMigration({
|
|
598
|
+
file,
|
|
599
|
+
absolutePath,
|
|
600
|
+
outputPath,
|
|
601
|
+
config,
|
|
602
|
+
key,
|
|
603
|
+
options
|
|
604
|
+
});
|
|
605
|
+
return exit(0);
|
|
606
|
+
}
|
|
607
|
+
|
|
255
608
|
if (command === 'encrypt') {
|
|
609
|
+
if (options.algorithm !== undefined && !options.legacy) {
|
|
610
|
+
throw cliError(
|
|
611
|
+
'ERR_INVALID_OPTION',
|
|
612
|
+
'encrypt requires --legacy when --algorithm is provided.'
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
if (options.errorOnEncrypted && options.forceEncrypt) {
|
|
616
|
+
throw cliError(
|
|
617
|
+
'ERR_INVALID_OPTION',
|
|
618
|
+
'encrypt cannot combine --error-on-encrypted with --force-encrypt.'
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
256
622
|
const result = processConfig(config.data, {
|
|
257
623
|
mode: 'encrypt',
|
|
258
624
|
key,
|
|
259
625
|
algorithm: options.algorithm,
|
|
626
|
+
formatVersion: options.legacy ? 1 : 2,
|
|
627
|
+
existingPayloadPolicy: options.forceEncrypt
|
|
628
|
+
? 'encrypt'
|
|
629
|
+
: options.errorOnEncrypted
|
|
630
|
+
? 'error'
|
|
631
|
+
: 'preserve',
|
|
260
632
|
paths: options.paths
|
|
261
633
|
});
|
|
634
|
+
if (outputPath === absolutePath && isDeepStrictEqual(result, config.data)) {
|
|
635
|
+
print('No plaintext values required encryption. No files were modified.');
|
|
636
|
+
return exit(0);
|
|
637
|
+
}
|
|
262
638
|
handleWrite({
|
|
263
639
|
dryRun: options.dryRun,
|
|
264
640
|
file,
|
|
641
|
+
absolutePath,
|
|
265
642
|
outputPath,
|
|
266
|
-
|
|
267
|
-
originalRaw: config.raw,
|
|
643
|
+
config,
|
|
268
644
|
data: result,
|
|
269
645
|
operation: 'encrypt'
|
|
270
646
|
});
|
|
@@ -275,15 +651,14 @@ export async function runCli(argv = process.argv) {
|
|
|
275
651
|
const result = processConfig(config.data, {
|
|
276
652
|
mode: 'decrypt',
|
|
277
653
|
key,
|
|
278
|
-
algorithm: options.algorithm,
|
|
279
654
|
paths: options.paths
|
|
280
655
|
});
|
|
281
656
|
handleWrite({
|
|
282
657
|
dryRun: options.dryRun,
|
|
283
658
|
file,
|
|
659
|
+
absolutePath,
|
|
284
660
|
outputPath,
|
|
285
|
-
|
|
286
|
-
originalRaw: config.raw,
|
|
661
|
+
config,
|
|
287
662
|
data: result,
|
|
288
663
|
operation: 'decrypt'
|
|
289
664
|
});
|
|
@@ -293,7 +668,15 @@ export async function runCli(argv = process.argv) {
|
|
|
293
668
|
print(getHelpText().trim());
|
|
294
669
|
return fail('ERR_UNKNOWN_COMMAND', `Unknown command: ${command}`);
|
|
295
670
|
} catch (error) {
|
|
296
|
-
|
|
671
|
+
const structuredCode = typeof error.code === 'string' && error.code.startsWith('ERR_')
|
|
672
|
+
? error.code
|
|
673
|
+
: null;
|
|
674
|
+
const code = command === 'migrate'
|
|
675
|
+
? structuredCode?.startsWith('ERR_MIGRATION_')
|
|
676
|
+
? structuredCode
|
|
677
|
+
: 'ERR_MIGRATION_FAILED'
|
|
678
|
+
: structuredCode ?? 'ERR_PROCESS_FAILED';
|
|
679
|
+
return fail(code, `Operation failed: ${error.message}`);
|
|
297
680
|
}
|
|
298
681
|
}
|
|
299
682
|
|