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/config.js ADDED
@@ -0,0 +1,167 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { VaultError, resolveInside, safeReadFile, safeWriteFile } from './utils.js';
4
+
5
+ export const CONFIG_FILENAME = '.dotcrypt.json';
6
+ export const CONFIG_VERSION = 1;
7
+ export const DEFAULT_ENVIRONMENTS = Object.freeze(['development', 'staging', 'production']);
8
+ export const ENV_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/i;
9
+ const MAX_CONFIG_SIZE = 256 * 1024;
10
+
11
+ function isPlainObject(value) {
12
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
13
+ }
14
+
15
+ function normalizeRelative(value) {
16
+ return path.posix.normalize(value.replace(/\\/g, '/'));
17
+ }
18
+
19
+ function relativePathProblem(value) {
20
+ if (typeof value !== 'string' || value.trim() === '') return 'must be a non-empty string';
21
+ if (value.includes('\0')) return 'must not contain null bytes';
22
+ if (path.isAbsolute(value) || path.win32.isAbsolute(value) || path.posix.isAbsolute(value)) {
23
+ return 'must be a path relative to the project root';
24
+ }
25
+ const normalized = normalizeRelative(value);
26
+ if (normalized === '.' || normalized === '..' || normalized.startsWith('../')) {
27
+ return 'must stay inside the project directory';
28
+ }
29
+ if (normalized.endsWith('/')) return 'must point to a file, not a directory';
30
+ return null;
31
+ }
32
+
33
+ export function defaultFileMapping(envName) {
34
+ return { file: `.env.${envName}`, encryptedFile: `.env.${envName}.enc` };
35
+ }
36
+
37
+ /**
38
+ * Validates a parsed .dotcrypt.json, collecting every problem into one error.
39
+ */
40
+ export function validateConfig(config) {
41
+ if (!isPlainObject(config)) throw new VaultError(`${CONFIG_FILENAME} must contain a JSON object.`);
42
+ const errors = [];
43
+
44
+ if (config.version !== CONFIG_VERSION) errors.push(`"version" must be ${CONFIG_VERSION}`);
45
+ if (!isPlainObject(config.vault) || typeof config.vault.name !== 'string' || !config.vault.name.trim()) {
46
+ errors.push('"vault.name" must be a non-empty string');
47
+ }
48
+
49
+ const envs = config.environments;
50
+ if (!isPlainObject(envs) || Object.keys(envs).length === 0) {
51
+ errors.push('"environments" must be an object containing at least one environment');
52
+ } else {
53
+ // Case-insensitive to stay safe on case-insensitive filesystems (Windows/macOS).
54
+ const usedPaths = new Map();
55
+ for (const [name, mapping] of Object.entries(envs)) {
56
+ const where = `environments.${name}`;
57
+ if (!ENV_NAME_PATTERN.test(name)) {
58
+ errors.push(`"${where}": name must be 1-64 chars of letters, digits, "-" or "_", starting and ending alphanumeric`);
59
+ continue;
60
+ }
61
+ if (!isPlainObject(mapping)) {
62
+ errors.push(`"${where}" must be an object with "file" and "encryptedFile"`);
63
+ continue;
64
+ }
65
+ for (const field of ['file', 'encryptedFile']) {
66
+ const value = mapping[field];
67
+ const problem = relativePathProblem(value);
68
+ if (problem) {
69
+ errors.push(`"${where}.${field}" ${problem}`);
70
+ continue;
71
+ }
72
+ const key = normalizeRelative(value).toLowerCase();
73
+ if (usedPaths.has(key)) errors.push(`"${where}.${field}" (${value}) is already used by "${usedPaths.get(key)}"`);
74
+ else usedPaths.set(key, `${where}.${field}`);
75
+ }
76
+ if (typeof mapping.file === 'string' && mapping.file.toLowerCase().endsWith('.enc')) {
77
+ errors.push(`"${where}.file" must be the plaintext file and must not end with ".enc"`);
78
+ }
79
+ if (typeof mapping.encryptedFile === 'string' && !mapping.encryptedFile.endsWith('.enc')) {
80
+ errors.push(`"${where}.encryptedFile" must end with ".enc"`);
81
+ }
82
+ }
83
+ }
84
+
85
+ if (config.defaultEnvironment !== undefined) {
86
+ if (!isPlainObject(envs) || typeof config.defaultEnvironment !== 'string' || !Object.hasOwn(envs, config.defaultEnvironment)) {
87
+ errors.push('"defaultEnvironment" must name one of the configured environments');
88
+ }
89
+ }
90
+
91
+ if (errors.length > 0) {
92
+ throw new VaultError(`Invalid ${CONFIG_FILENAME}:\n - ${errors.join('\n - ')}`);
93
+ }
94
+ return config;
95
+ }
96
+
97
+ export function createConfig({ name, environments = DEFAULT_ENVIRONMENTS, defaultEnvironment } = {}) {
98
+ const unique = [...new Set(environments.map((env) => env.trim()).filter(Boolean))];
99
+ const config = {
100
+ version: CONFIG_VERSION,
101
+ vault: {
102
+ name,
103
+ createdAt: new Date().toISOString(),
104
+ cipher: 'aes-256-gcm',
105
+ kdf: 'scrypt',
106
+ },
107
+ defaultEnvironment: defaultEnvironment ?? (unique.includes('development') ? 'development' : unique[0]),
108
+ environments: Object.fromEntries(unique.map((env) => [env, defaultFileMapping(env)])),
109
+ };
110
+ return validateConfig(config);
111
+ }
112
+
113
+ /** Walks up from startDir to find the directory containing .dotcrypt.json. */
114
+ export function findProjectRoot(startDir = process.cwd()) {
115
+ let dir = path.resolve(startDir);
116
+ for (;;) {
117
+ if (fs.existsSync(path.join(dir, CONFIG_FILENAME))) return dir;
118
+ const parent = path.dirname(dir);
119
+ if (parent === dir) return null;
120
+ dir = parent;
121
+ }
122
+ }
123
+
124
+ export function loadConfig(startDir = process.cwd()) {
125
+ const root = findProjectRoot(startDir);
126
+ if (!root) {
127
+ throw new VaultError(`No ${CONFIG_FILENAME} found in ${path.resolve(startDir)} or any parent directory.`, {
128
+ code: 'NO_CONFIG',
129
+ hint: 'Run "dotcrypt init" at the root of your project.',
130
+ });
131
+ }
132
+ const configPath = path.join(root, CONFIG_FILENAME);
133
+ const raw = safeReadFile(root, configPath, { maxSize: MAX_CONFIG_SIZE });
134
+ let parsed;
135
+ try {
136
+ parsed = JSON.parse(raw.toString('utf8'));
137
+ } catch (err) {
138
+ throw new VaultError(`${CONFIG_FILENAME} is not valid JSON: ${err.message}`);
139
+ }
140
+ return { root, configPath, config: validateConfig(parsed) };
141
+ }
142
+
143
+ export function saveConfig(root, config) {
144
+ validateConfig(config);
145
+ safeWriteFile(root, path.join(root, CONFIG_FILENAME), `${JSON.stringify(config, null, 2)}\n`, { mode: 0o644 });
146
+ }
147
+
148
+ export function listEnvironments(config) {
149
+ return Object.keys(config.environments);
150
+ }
151
+
152
+ export function resolveEnvironment({ root, config }, name) {
153
+ if (!Object.hasOwn(config.environments, name)) {
154
+ throw new VaultError(`Unknown environment "${name}".`, {
155
+ code: 'NOT_FOUND',
156
+ hint: `Available environments: ${listEnvironments(config).join(', ')}`,
157
+ });
158
+ }
159
+ const mapping = config.environments[name];
160
+ return {
161
+ name,
162
+ file: mapping.file,
163
+ encryptedFile: mapping.encryptedFile,
164
+ plainPath: resolveInside(root, mapping.file),
165
+ encPath: resolveInside(root, mapping.encryptedFile),
166
+ };
167
+ }
package/src/crypto.js ADDED
@@ -0,0 +1,259 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ export const FORMAT_VERSION = 1;
4
+ export const ALGORITHM = 'aes-256-gcm';
5
+ export const KDF_NAME = 'scrypt';
6
+ export const KEY_LENGTH = 32; // 256-bit key
7
+ export const IV_LENGTH = 16;
8
+ export const SALT_LENGTH = 32;
9
+ export const MIN_SALT_LENGTH = 16;
10
+ export const AUTH_TAG_LENGTH = 16;
11
+
12
+ // N=2^15, r=8 => ~32 MiB of memory per derivation (OWASP-recommended baseline).
13
+ export const DEFAULT_SCRYPT_PARAMS = Object.freeze({ N: 2 ** 15, r: 8, p: 1 });
14
+
15
+ // Bounds applied to parameters read from (untrusted) encrypted files: the lower
16
+ // bounds prevent KDF downgrade attacks, the upper bounds prevent memory/CPU DoS.
17
+ const SCRYPT_BOUNDS = Object.freeze({ minN: 2 ** 14, maxN: 2 ** 20, minR: 8, maxR: 32, minP: 1, maxP: 16 });
18
+ const MAX_SCRYPT_MEMORY = 256 * 1024 * 1024;
19
+
20
+ const ENV_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/i;
21
+ const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
22
+
23
+ export class DecryptionError extends Error {
24
+ constructor(message) {
25
+ super(message);
26
+ this.name = 'DecryptionError';
27
+ }
28
+ }
29
+
30
+ export class EnvelopeFormatError extends Error {
31
+ constructor(message) {
32
+ super(message);
33
+ this.name = 'EnvelopeFormatError';
34
+ }
35
+ }
36
+
37
+ function scryptMemory({ N, r, p }) {
38
+ return 128 * N * r + 128 * r * p;
39
+ }
40
+
41
+ export function validateScryptParams(params) {
42
+ const { N, r, p } = params ?? {};
43
+ if (![N, r, p].every(Number.isSafeInteger)) {
44
+ throw new EnvelopeFormatError('scrypt parameters N, r and p must be integers.');
45
+ }
46
+ if (N < SCRYPT_BOUNDS.minN || N > SCRYPT_BOUNDS.maxN || (N & (N - 1)) !== 0) {
47
+ throw new EnvelopeFormatError(
48
+ `scrypt N must be a power of two between ${SCRYPT_BOUNDS.minN} and ${SCRYPT_BOUNDS.maxN}.`,
49
+ );
50
+ }
51
+ if (r < SCRYPT_BOUNDS.minR || r > SCRYPT_BOUNDS.maxR) {
52
+ throw new EnvelopeFormatError(`scrypt r must be between ${SCRYPT_BOUNDS.minR} and ${SCRYPT_BOUNDS.maxR}.`);
53
+ }
54
+ if (p < SCRYPT_BOUNDS.minP || p > SCRYPT_BOUNDS.maxP) {
55
+ throw new EnvelopeFormatError(`scrypt p must be between ${SCRYPT_BOUNDS.minP} and ${SCRYPT_BOUNDS.maxP}.`);
56
+ }
57
+ if (scryptMemory({ N, r, p }) > MAX_SCRYPT_MEMORY) {
58
+ throw new EnvelopeFormatError('scrypt parameters exceed the allowed memory budget.');
59
+ }
60
+ return { N, r, p };
61
+ }
62
+
63
+ function assertPassword(password) {
64
+ if (typeof password !== 'string' || password.length === 0) {
65
+ throw new TypeError('Password must be a non-empty string.');
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Derives a 256-bit key from a password using scrypt.
71
+ * The password is NFC-normalized so the same visual password yields the same key
72
+ * on every OS / keyboard layout.
73
+ */
74
+ export function deriveKey(password, salt, params = DEFAULT_SCRYPT_PARAMS) {
75
+ assertPassword(password);
76
+ if (!Buffer.isBuffer(salt) || salt.length < MIN_SALT_LENGTH) {
77
+ throw new TypeError(`Salt must be a Buffer of at least ${MIN_SALT_LENGTH} bytes.`);
78
+ }
79
+ const { N, r, p } = validateScryptParams(params);
80
+ const passwordBytes = Buffer.from(password.normalize('NFC'), 'utf8');
81
+ try {
82
+ return crypto.scryptSync(passwordBytes, salt, KEY_LENGTH, {
83
+ N,
84
+ r,
85
+ p,
86
+ maxmem: scryptMemory({ N, r, p }) + 16 * 1024 * 1024,
87
+ });
88
+ } finally {
89
+ passwordBytes.fill(0);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Additional Authenticated Data: binds every header field (format, KDF params,
95
+ * salt, IV, environment metadata) to the ciphertext so none of it can be
96
+ * altered, downgraded, or swapped between environments without detection.
97
+ */
98
+ function buildAad(header) {
99
+ return Buffer.from(
100
+ JSON.stringify([
101
+ 'dotcrypt',
102
+ header.version,
103
+ header.algorithm,
104
+ header.kdf.name,
105
+ header.kdf.N,
106
+ header.kdf.r,
107
+ header.kdf.p,
108
+ header.kdf.salt,
109
+ header.iv,
110
+ header.metadata.environment,
111
+ header.metadata.sourceFile,
112
+ header.metadata.encryptedAt,
113
+ ]),
114
+ 'utf8',
115
+ );
116
+ }
117
+
118
+ function decodeBase64(value, field) {
119
+ if (typeof value !== 'string' || !BASE64_PATTERN.test(value)) {
120
+ throw new EnvelopeFormatError(`"${field}" must be a valid base64 string.`);
121
+ }
122
+ return Buffer.from(value, 'base64');
123
+ }
124
+
125
+ function isPlainObject(value) {
126
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
127
+ }
128
+
129
+ /**
130
+ * Strictly validates an envelope object and returns its decoded binary fields.
131
+ */
132
+ export function validateEnvelope(envelope) {
133
+ if (!isPlainObject(envelope)) throw new EnvelopeFormatError('Encrypted file must contain a JSON object.');
134
+ if (envelope.version !== FORMAT_VERSION) {
135
+ throw new EnvelopeFormatError(`Unsupported format version: ${JSON.stringify(envelope.version)}.`);
136
+ }
137
+ if (envelope.algorithm !== ALGORITHM) {
138
+ throw new EnvelopeFormatError(`Unsupported algorithm: ${JSON.stringify(envelope.algorithm)}.`);
139
+ }
140
+ if (!isPlainObject(envelope.kdf) || envelope.kdf.name !== KDF_NAME) {
141
+ throw new EnvelopeFormatError('Unsupported or missing key derivation function.');
142
+ }
143
+ validateScryptParams(envelope.kdf);
144
+
145
+ const meta = envelope.metadata;
146
+ if (!isPlainObject(meta)) throw new EnvelopeFormatError('Missing "metadata" object.');
147
+ if (typeof meta.environment !== 'string' || !ENV_NAME_PATTERN.test(meta.environment)) {
148
+ throw new EnvelopeFormatError('"metadata.environment" is missing or invalid.');
149
+ }
150
+ if (typeof meta.sourceFile !== 'string' || typeof meta.encryptedAt !== 'string') {
151
+ throw new EnvelopeFormatError('"metadata.sourceFile" and "metadata.encryptedAt" must be strings.');
152
+ }
153
+
154
+ const salt = decodeBase64(envelope.kdf.salt, 'kdf.salt');
155
+ const iv = decodeBase64(envelope.iv, 'iv');
156
+ const authTag = decodeBase64(envelope.authTag, 'authTag');
157
+ const ciphertext = decodeBase64(envelope.ciphertext, 'ciphertext');
158
+
159
+ if (salt.length < MIN_SALT_LENGTH || salt.length > 64) {
160
+ throw new EnvelopeFormatError(`Salt must be between ${MIN_SALT_LENGTH} and 64 bytes.`);
161
+ }
162
+ if (iv.length !== IV_LENGTH) throw new EnvelopeFormatError(`IV must be exactly ${IV_LENGTH} bytes.`);
163
+ if (authTag.length !== AUTH_TAG_LENGTH) {
164
+ throw new EnvelopeFormatError(`Authentication tag must be exactly ${AUTH_TAG_LENGTH} bytes.`);
165
+ }
166
+ return { salt, iv, authTag, ciphertext };
167
+ }
168
+
169
+ /**
170
+ * Encrypts plaintext with AES-256-GCM using a fresh random salt and IV per call.
171
+ * @returns {object} JSON-serializable envelope.
172
+ */
173
+ export function encrypt(plaintext, password, { environment, sourceFile, scryptParams = DEFAULT_SCRYPT_PARAMS } = {}) {
174
+ assertPassword(password);
175
+ if (typeof environment !== 'string' || !ENV_NAME_PATTERN.test(environment)) {
176
+ throw new TypeError('A valid environment name is required.');
177
+ }
178
+ if (typeof sourceFile !== 'string' || sourceFile.length === 0) {
179
+ throw new TypeError('sourceFile is required.');
180
+ }
181
+ const data = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(String(plaintext), 'utf8');
182
+ const { N, r, p } = validateScryptParams(scryptParams);
183
+
184
+ const salt = crypto.randomBytes(SALT_LENGTH);
185
+ const iv = crypto.randomBytes(IV_LENGTH);
186
+ const header = {
187
+ version: FORMAT_VERSION,
188
+ algorithm: ALGORITHM,
189
+ kdf: { name: KDF_NAME, N, r, p, salt: salt.toString('base64') },
190
+ iv: iv.toString('base64'),
191
+ metadata: { environment, sourceFile, encryptedAt: new Date().toISOString() },
192
+ };
193
+
194
+ const key = deriveKey(password, salt, { N, r, p });
195
+ try {
196
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
197
+ cipher.setAAD(buildAad(header));
198
+ const ciphertext = Buffer.concat([cipher.update(data), cipher.final()]);
199
+ return {
200
+ ...header,
201
+ authTag: cipher.getAuthTag().toString('base64'),
202
+ ciphertext: ciphertext.toString('base64'),
203
+ };
204
+ } finally {
205
+ key.fill(0);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Decrypts and authenticates an envelope.
211
+ * @throws {DecryptionError} on wrong password, tampering, or environment mismatch.
212
+ * @returns {Buffer} plaintext (caller should zero it when done).
213
+ */
214
+ export function decrypt(envelope, password, { expectedEnvironment } = {}) {
215
+ assertPassword(password);
216
+ const { salt, iv, authTag, ciphertext } = validateEnvelope(envelope);
217
+ const key = deriveKey(password, salt, envelope.kdf);
218
+
219
+ let plaintext;
220
+ try {
221
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
222
+ decipher.setAAD(buildAad(envelope));
223
+ decipher.setAuthTag(authTag);
224
+ try {
225
+ plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
226
+ } catch {
227
+ // GCM cannot (and must not) distinguish a wrong key from tampered data.
228
+ throw new DecryptionError(
229
+ 'Decryption failed: the password is incorrect, or the encrypted file is corrupted or has been tampered with.',
230
+ );
231
+ }
232
+ } finally {
233
+ key.fill(0);
234
+ }
235
+
236
+ if (expectedEnvironment !== undefined && envelope.metadata.environment !== expectedEnvironment) {
237
+ plaintext.fill(0);
238
+ throw new DecryptionError(
239
+ `Encrypted file belongs to environment "${envelope.metadata.environment}", expected "${expectedEnvironment}".`,
240
+ );
241
+ }
242
+ return plaintext;
243
+ }
244
+
245
+ export function serializeEnvelope(envelope) {
246
+ validateEnvelope(envelope);
247
+ return `${JSON.stringify(envelope, null, 2)}\n`;
248
+ }
249
+
250
+ export function parseEnvelope(text) {
251
+ let parsed;
252
+ try {
253
+ parsed = JSON.parse(Buffer.isBuffer(text) ? text.toString('utf8') : text);
254
+ } catch (err) {
255
+ throw new EnvelopeFormatError(`Encrypted file is not valid JSON (${err.message}).`);
256
+ }
257
+ validateEnvelope(parsed);
258
+ return parsed;
259
+ }