dotcrypt 0.0.0-stage → 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/src/vault.js ADDED
@@ -0,0 +1,391 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import {
5
+ CONFIG_FILENAME,
6
+ ENV_NAME_PATTERN,
7
+ createConfig,
8
+ defaultFileMapping,
9
+ listEnvironments,
10
+ resolveEnvironment,
11
+ saveConfig,
12
+ } from './config.js';
13
+ import { DecryptionError, EnvelopeFormatError, decrypt, encrypt, parseEnvelope, serializeEnvelope } from './crypto.js';
14
+ import {
15
+ ConfirmationRequiredError,
16
+ VaultError,
17
+ buffersEqual,
18
+ diffEnv,
19
+ ensureGitignore,
20
+ fileExists,
21
+ gitStatusOf,
22
+ parseEnv,
23
+ safeReadFile,
24
+ safeWriteFile,
25
+ } from './utils.js';
26
+
27
+ export const MIN_PASSWORD_LENGTH = 12;
28
+
29
+ export function newPasswordProblem(password) {
30
+ if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) {
31
+ return `Password must be at least ${MIN_PASSWORD_LENGTH} characters long.`;
32
+ }
33
+ if (password.trim() !== password) return 'Password must not start or end with whitespace.';
34
+ return null;
35
+ }
36
+
37
+ export function assertNewPassword(password) {
38
+ const problem = newPasswordProblem(password);
39
+ if (problem) throw new VaultError(problem, { code: 'WEAK_PASSWORD' });
40
+ }
41
+
42
+ function assertPasswordProvided(password, label = 'Password') {
43
+ if (typeof password !== 'string' || password.length === 0) {
44
+ throw new VaultError(`${label} is required.`, { code: 'INVALID_INPUT' });
45
+ }
46
+ }
47
+
48
+ /** Plaintext-exposure warnings for a file path (empty when safe or not a Git repo). */
49
+ export function plaintextWarnings(root, relFile) {
50
+ const status = gitStatusOf(root, relFile);
51
+ if (!status.inRepo) return [];
52
+ if (status.tracked) {
53
+ return [
54
+ `${relFile} is TRACKED by Git! Remove it with "git rm --cached ${relFile}" and rotate the secrets it contains.`,
55
+ ];
56
+ }
57
+ if (!status.ignored) return [`${relFile} is not ignored by Git. Add it to .gitignore.`];
58
+ return [];
59
+ }
60
+
61
+ export function loadEnvelope(ctx, env) {
62
+ if (!fileExists(env.encPath)) {
63
+ throw new VaultError(`Encrypted file ${env.encryptedFile} not found for environment "${env.name}".`, {
64
+ code: 'NOT_FOUND',
65
+ hint: `Run "dotcrypt encrypt -e ${env.name}" first, or pull the latest changes from Git.`,
66
+ });
67
+ }
68
+ try {
69
+ return parseEnvelope(safeReadFile(ctx.root, env.encPath));
70
+ } catch (err) {
71
+ if (err instanceof EnvelopeFormatError) {
72
+ throw new VaultError(`${env.encryptedFile} is not a valid vault file: ${err.message}`, { code: 'INVALID_VAULT' });
73
+ }
74
+ throw err;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Classifies an environment's vault file without a password:
80
+ * "missing", "valid" (with its envelope), or "unreadable" (with the problem).
81
+ */
82
+ export function inspectVault(ctx, env) {
83
+ if (!fileExists(env.encPath)) return { status: 'missing' };
84
+ try {
85
+ const envelope = loadEnvelope(ctx, env);
86
+ if (envelope.metadata.environment !== env.name) {
87
+ return {
88
+ status: 'unreadable',
89
+ problem: `${env.encryptedFile} belongs to environment "${envelope.metadata.environment}", not "${env.name}".`,
90
+ };
91
+ }
92
+ return { status: 'valid', envelope };
93
+ } catch (err) {
94
+ if (err instanceof VaultError && err.code === 'INVALID_VAULT') return { status: 'unreadable', problem: err.message };
95
+ throw err;
96
+ }
97
+ }
98
+
99
+ function decryptOrFail(envelope, password, env) {
100
+ try {
101
+ return decrypt(envelope, password, { expectedEnvironment: env.name });
102
+ } catch (err) {
103
+ if (err instanceof DecryptionError) throw new VaultError(err.message, { exitCode: 2, code: 'DECRYPT_FAILED' });
104
+ throw err;
105
+ }
106
+ }
107
+
108
+ function fileInfo(root, absPath) {
109
+ try {
110
+ if (!fileExists(absPath)) return { exists: false };
111
+ const stat = fs.statSync(absPath);
112
+ return { exists: true, size: stat.size, modifiedAt: stat.mtime.toISOString() };
113
+ } catch (err) {
114
+ return { exists: true, error: err.message };
115
+ }
116
+ }
117
+
118
+ /** Describes an environment without decrypting anything. Never exposes secret values. */
119
+ export function getEnvironmentStatus(ctx, name) {
120
+ const env = resolveEnvironment(ctx, name);
121
+ const local = fileInfo(ctx.root, env.plainPath);
122
+ const vault = { ...fileInfo(ctx.root, env.encPath), valid: false };
123
+
124
+ if (vault.exists && !vault.error) {
125
+ try {
126
+ const envelope = loadEnvelope(ctx, env);
127
+ vault.valid = true;
128
+ vault.encryptedAt = envelope.metadata.encryptedAt;
129
+ vault.environment = envelope.metadata.environment;
130
+ vault.environmentMatches = envelope.metadata.environment === env.name;
131
+ vault.kdf = { N: envelope.kdf.N, r: envelope.kdf.r, p: envelope.kdf.p };
132
+ } catch (err) {
133
+ vault.error = err.message;
134
+ }
135
+ }
136
+
137
+ const git = gitStatusOf(ctx.root, env.file);
138
+ const vaultGit = gitStatusOf(ctx.root, env.encryptedFile);
139
+ const localNewerThanVault = Boolean(
140
+ local.modifiedAt && vault.encryptedAt && Date.parse(local.modifiedAt) > Date.parse(vault.encryptedAt),
141
+ );
142
+
143
+ return {
144
+ name: env.name,
145
+ file: env.file,
146
+ encryptedFile: env.encryptedFile,
147
+ isDefault: ctx.config.defaultEnvironment === env.name,
148
+ local,
149
+ vault,
150
+ git,
151
+ vaultGit: vaultGit.inRepo ? { inRepo: true, tracked: vaultGit.tracked } : { inRepo: false },
152
+ localNewerThanVault,
153
+ };
154
+ }
155
+
156
+ export function getVaultStatus(ctx) {
157
+ const environments = listEnvironments(ctx.config).map((name) => getEnvironmentStatus(ctx, name));
158
+ return {
159
+ root: ctx.root,
160
+ configFile: CONFIG_FILENAME,
161
+ vault: ctx.config.vault,
162
+ defaultEnvironment: ctx.config.defaultEnvironment,
163
+ environments,
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Encrypts the local file into the vault file.
169
+ * Options:
170
+ * - overwrite: replace an existing vault file whose contents differ.
171
+ * - replaceExisting: discard an existing vault file that is unreadable or protected by another password.
172
+ * @throws {ConfirmationRequiredError} when approval is needed.
173
+ */
174
+ export function encryptEnvironment(ctx, name, password, { overwrite = false, replaceExisting = false } = {}) {
175
+ assertPasswordProvided(password);
176
+ const env = resolveEnvironment(ctx, name);
177
+ if (!fileExists(env.plainPath)) {
178
+ throw new VaultError(`Local file ${env.file} not found for environment "${env.name}".`, { code: 'NOT_FOUND' });
179
+ }
180
+ const warnings = plaintextWarnings(ctx.root, env.file);
181
+ const plaintext = safeReadFile(ctx.root, env.plainPath);
182
+
183
+ try {
184
+ const vaultState = inspectVault(ctx, env);
185
+ const vaultExisted = vaultState.status !== 'missing';
186
+ const existing = vaultState.status === 'valid' ? vaultState.envelope : null;
187
+ if (vaultState.status === 'unreadable') {
188
+ if (!replaceExisting) {
189
+ throw new ConfirmationRequiredError(vaultState.problem, {
190
+ reason: 'unreadable-vault',
191
+ hint: 'Replacing it creates a new vault file with this password.',
192
+ });
193
+ }
194
+ warnings.push(`Replaced unreadable ${env.encryptedFile}.`);
195
+ }
196
+
197
+ let previous = null;
198
+ if (existing) {
199
+ try {
200
+ previous = decrypt(existing, password, { expectedEnvironment: env.name });
201
+ } catch (err) {
202
+ if (!(err instanceof DecryptionError)) throw err;
203
+ // Guards against a teammate accidentally splitting the vault onto a different password.
204
+ if (!replaceExisting) {
205
+ throw new ConfirmationRequiredError(
206
+ `Cannot verify the password against the existing ${env.encryptedFile}. ${err.message}`,
207
+ {
208
+ reason: 'password-mismatch',
209
+ hint: 'To change the password use rotate-key. Replacing discards the existing vault file.',
210
+ },
211
+ );
212
+ }
213
+ warnings.push(`Replaced ${env.encryptedFile}, which was protected by a different password.`);
214
+ }
215
+ }
216
+ if (!previous) assertNewPassword(password);
217
+
218
+ let diff;
219
+ if (previous) {
220
+ try {
221
+ if (buffersEqual(previous, plaintext)) return { status: 'unchanged', environment: env.name, warnings };
222
+ diff = diffEnv(parseEnv(previous), parseEnv(plaintext));
223
+ } finally {
224
+ previous.fill(0);
225
+ }
226
+ if (!overwrite) {
227
+ throw new ConfirmationRequiredError(`Overwrite ${env.encryptedFile} with the contents of ${env.file}?`, {
228
+ reason: 'overwrite',
229
+ diff,
230
+ });
231
+ }
232
+ }
233
+
234
+ const envelope = encrypt(plaintext, password, { environment: env.name, sourceFile: env.file });
235
+ safeWriteFile(ctx.root, env.encPath, serializeEnvelope(envelope), { mode: 0o644 });
236
+ return { status: vaultExisted ? 'updated' : 'created', environment: env.name, diff, warnings };
237
+ } finally {
238
+ plaintext.fill(0);
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Decrypts the vault file into the local file.
244
+ * @throws {ConfirmationRequiredError} when the local file differs and overwrite is false.
245
+ */
246
+ export function decryptEnvironment(ctx, name, password, { overwrite = false } = {}) {
247
+ assertPasswordProvided(password);
248
+ const env = resolveEnvironment(ctx, name);
249
+ const envelope = loadEnvelope(ctx, env);
250
+ const plaintext = decryptOrFail(envelope, password, env);
251
+
252
+ try {
253
+ const localExisted = fileExists(env.plainPath);
254
+ if (localExisted) {
255
+ const current = safeReadFile(ctx.root, env.plainPath);
256
+ try {
257
+ if (buffersEqual(current, plaintext)) {
258
+ return { status: 'unchanged', environment: env.name, warnings: plaintextWarnings(ctx.root, env.file) };
259
+ }
260
+ if (!overwrite) {
261
+ throw new ConfirmationRequiredError(`Overwrite local ${env.file}?`, {
262
+ reason: 'overwrite',
263
+ diff: diffEnv(parseEnv(plaintext), parseEnv(current)),
264
+ });
265
+ }
266
+ } finally {
267
+ current.fill(0);
268
+ }
269
+ }
270
+
271
+ safeWriteFile(ctx.root, env.plainPath, plaintext, { mode: 0o600 });
272
+ return {
273
+ status: localExisted ? 'updated' : 'created',
274
+ environment: env.name,
275
+ warnings: plaintextWarnings(ctx.root, env.file),
276
+ };
277
+ } finally {
278
+ plaintext.fill(0);
279
+ }
280
+ }
281
+
282
+ export function rotateEnvironmentKey(ctx, name, oldPassword, newPassword) {
283
+ assertPasswordProvided(oldPassword, 'Current password');
284
+ assertPasswordProvided(newPassword, 'New password');
285
+ const env = resolveEnvironment(ctx, name);
286
+ const envelope = loadEnvelope(ctx, env);
287
+ const plaintext = decryptOrFail(envelope, oldPassword, env);
288
+
289
+ try {
290
+ assertNewPassword(newPassword);
291
+ if (newPassword === oldPassword) {
292
+ throw new VaultError('The new password must differ from the current password.', { code: 'INVALID_INPUT' });
293
+ }
294
+ const rotated = encrypt(plaintext, newPassword, { environment: env.name, sourceFile: env.file });
295
+
296
+ // Verify the new file round-trips before replacing the old one.
297
+ const check = decrypt(rotated, newPassword, { expectedEnvironment: env.name });
298
+ const ok = buffersEqual(check, plaintext);
299
+ check.fill(0);
300
+ if (!ok) throw new VaultError('Verification of the re-encrypted data failed. The original file was not modified.');
301
+
302
+ safeWriteFile(ctx.root, env.encPath, serializeEnvelope(rotated), { mode: 0o644 });
303
+ return { status: 'rotated', environment: env.name };
304
+ } finally {
305
+ plaintext.fill(0);
306
+ }
307
+ }
308
+
309
+ /** Throws DECRYPT_FAILED if the password cannot open the environment's vault file. */
310
+ export function verifyPassword(ctx, name, password) {
311
+ assertPasswordProvided(password);
312
+ const env = resolveEnvironment(ctx, name);
313
+ decryptOrFail(loadEnvelope(ctx, env), password, env).fill(0);
314
+ }
315
+
316
+ /**
317
+ * Key-level comparison of vault vs local. Returns key names only.
318
+ * `identical` is true only when both files are byte-for-byte equal (comments and formatting included).
319
+ */
320
+ export function diffEnvironment(ctx, name, password) {
321
+ assertPasswordProvided(password);
322
+ const env = resolveEnvironment(ctx, name);
323
+ const envelope = loadEnvelope(ctx, env);
324
+ const plaintext = decryptOrFail(envelope, password, env);
325
+
326
+ const localExists = fileExists(env.plainPath);
327
+ let local = null;
328
+ let vaultVars;
329
+ let localVars = {};
330
+ let identical = false;
331
+ try {
332
+ if (localExists) local = safeReadFile(ctx.root, env.plainPath);
333
+ vaultVars = parseEnv(plaintext);
334
+ if (local) {
335
+ localVars = parseEnv(local);
336
+ identical = buffersEqual(local, plaintext);
337
+ }
338
+ } finally {
339
+ plaintext.fill(0);
340
+ local?.fill(0);
341
+ }
342
+ return { environment: env.name, localExists, identical, diff: diffEnv(vaultVars, localVars) };
343
+ }
344
+
345
+ /**
346
+ * Creates .dotcrypt.json in root and protects plaintext files in .gitignore.
347
+ * @throws {ConfirmationRequiredError} when a config already exists and overwrite is false.
348
+ */
349
+ export function initVault(root, { name, environments, defaultEnvironment, overwrite = false } = {}) {
350
+ if (fs.existsSync(path.join(root, CONFIG_FILENAME)) && !overwrite) {
351
+ throw new ConfirmationRequiredError(`${CONFIG_FILENAME} already exists. Overwrite it?`, { reason: 'config-exists' });
352
+ }
353
+ const config = createConfig({ name: name ?? path.basename(root), environments, defaultEnvironment });
354
+ saveConfig(root, config);
355
+
356
+ const plaintextFiles = Object.values(config.environments).map((env) => env.file);
357
+ const gitignoreAdded = ensureGitignore(root, plaintextFiles);
358
+ const warnings = ['.env', ...plaintextFiles]
359
+ .filter((file) => fs.existsSync(path.join(root, file)))
360
+ .flatMap((file) => plaintextWarnings(root, file));
361
+ return { config, gitignoreAdded, warnings };
362
+ }
363
+
364
+ export function addEnvironment(ctx, { name, file, encryptedFile } = {}) {
365
+ if (typeof name !== 'string' || !ENV_NAME_PATTERN.test(name)) {
366
+ throw new VaultError('Environment name must be 1-64 letters, digits, "-" or "_", starting and ending alphanumeric.', {
367
+ code: 'INVALID_INPUT',
368
+ });
369
+ }
370
+ if (Object.hasOwn(ctx.config.environments, name)) {
371
+ throw new VaultError(`Environment "${name}" already exists.`, { code: 'INVALID_INPUT' });
372
+ }
373
+ const defaults = defaultFileMapping(name);
374
+ const mapping = { file: file || defaults.file, encryptedFile: encryptedFile || defaults.encryptedFile };
375
+ const config = { ...ctx.config, environments: { ...ctx.config.environments, [name]: mapping } };
376
+ saveConfig(ctx.root, config);
377
+ const gitignoreAdded = ensureGitignore(ctx.root, [mapping.file]);
378
+ return { config, gitignoreAdded };
379
+ }
380
+
381
+ export function setDefaultEnvironment(ctx, name) {
382
+ resolveEnvironment(ctx, name);
383
+ const config = { ...ctx.config, defaultEnvironment: name };
384
+ saveConfig(ctx.root, config);
385
+ return { config };
386
+ }
387
+
388
+ export function repairGitignore(ctx, { dryRun = false } = {}) {
389
+ const files = Object.values(ctx.config.environments).map((env) => env.file);
390
+ return { gitignoreAdded: ensureGitignore(ctx.root, files, { dryRun }) };
391
+ }
package/ui/README.md ADDED
@@ -0,0 +1,4 @@
1
+ # dotcrypt dashboard
2
+
3
+ React + shadcn/ui (Base UI) dashboard served by `dotcrypt ui`. See the root README's *Web dashboard* section for usage, development and the security model.
4
+