dotenv-gad 1.4.2 → 1.6.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 +81 -11
- package/dist/cli/commands/check.js +10 -2
- package/dist/cli/commands/decrypt.js +126 -0
- package/dist/cli/commands/docs.js +2 -2
- package/dist/cli/commands/encrypt.js +100 -0
- package/dist/cli/commands/fix.js +16 -12
- package/dist/cli/commands/init.js +17 -12
- package/dist/cli/commands/keygen.js +97 -0
- package/dist/cli/commands/rotate.js +160 -0
- package/dist/cli/commands/status.js +116 -0
- package/dist/cli/commands/sync.js +15 -17
- package/dist/cli/commands/utils.js +40 -28
- package/dist/cli/commands/verify.js +101 -0
- package/dist/cli/index.js +12 -0
- package/dist/crypto.js +165 -0
- package/dist/errors.js +92 -4
- package/dist/index.cjs +408 -26
- package/dist/index.js +6 -4
- package/dist/runtime.js +43 -0
- package/dist/schema-loader.js +12 -3
- package/dist/types/cli/commands/check.d.ts +1 -1
- package/dist/types/cli/commands/decrypt.d.ts +2 -0
- package/dist/types/cli/commands/docs.d.ts +1 -1
- package/dist/types/cli/commands/encrypt.d.ts +2 -0
- package/dist/types/cli/commands/fix.d.ts +1 -1
- package/dist/types/cli/commands/init.d.ts +1 -1
- package/dist/types/cli/commands/keygen.d.ts +2 -0
- package/dist/types/cli/commands/rotate.d.ts +2 -0
- package/dist/types/cli/commands/status.d.ts +2 -0
- package/dist/types/cli/commands/sync.d.ts +1 -1
- package/dist/types/cli/commands/verify.d.ts +2 -0
- package/dist/types/crypto.d.ts +58 -0
- package/dist/types/errors.d.ts +35 -12
- package/dist/types/index.d.ts +8 -3
- package/dist/types/runtime.d.ts +29 -0
- package/dist/types/schema-loader.d.ts +5 -2
- package/dist/types/schema.d.ts +7 -0
- package/dist/types/utils.d.ts +21 -10
- package/dist/types/validator.d.ts +40 -0
- package/dist/utils.js +50 -13
- package/dist/validator.js +164 -19
- package/package.json +6 -4
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { createPrivateKey, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, } from "node:crypto";
|
|
2
|
+
import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { DecryptionFailedError } from "./errors.js";
|
|
5
|
+
import { getEnv } from "./runtime.js";
|
|
6
|
+
const SPKI_PREFIX = Buffer.from("302a300506032b656e032100", "hex");
|
|
7
|
+
const RAW_KEY_LENGTH = 32;
|
|
8
|
+
const NONCE_LENGTH = 12;
|
|
9
|
+
const AUTH_TAG_LENGTH = 16;
|
|
10
|
+
const PROTOCOL_PREFIX = "encrypted:";
|
|
11
|
+
const ENCRYPTION_VERSION = "v1";
|
|
12
|
+
const HKDF_INFO = Buffer.from("dotenv-gad:v1");
|
|
13
|
+
const HKDF_SALT = Buffer.alloc(0);
|
|
14
|
+
const AAD_PREFIX = "dotenv-gad:v1:";
|
|
15
|
+
const PRIVATE_KEY_HEX_LENGTH = 96;
|
|
16
|
+
const PUBLIC_KEY_HEX_LENGTH = 88;
|
|
17
|
+
/**
|
|
18
|
+
* Generates a new key pair using the X25519 curve.
|
|
19
|
+
*
|
|
20
|
+
* @returns An object containing the hex-encoded public and private keys.
|
|
21
|
+
* The public key is safe to commit to version control (e.g. .env), while the private key
|
|
22
|
+
* should be kept secret and stored securely (e.g. .env.keys).
|
|
23
|
+
*/
|
|
24
|
+
export function generateKeyPair() {
|
|
25
|
+
const { publicKey, privateKey } = generateKeyPairSync("x25519", {
|
|
26
|
+
publicKeyEncoding: { type: "spki", format: "der" },
|
|
27
|
+
privateKeyEncoding: { type: "pkcs8", format: "der" },
|
|
28
|
+
});
|
|
29
|
+
return {
|
|
30
|
+
publicKeyHex: publicKey.toString("hex"),
|
|
31
|
+
privateKeyHex: privateKey.toString("hex"),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Encrypts a plaintext string using ChaCha20-Poly1305 authenticated encryption.
|
|
36
|
+
* The encryption key is derived from the shared secret of the ephemeral key pair
|
|
37
|
+
* and the recipient's public key using HKDF-SHA256.
|
|
38
|
+
*
|
|
39
|
+
* @param plaintext - The plaintext string to encrypt.
|
|
40
|
+
* @param recipientPublicKeyHex - Hex-encoded 44-byte SPKI DER of the recipient's public key.
|
|
41
|
+
* @param varName - The name of the environment variable being encrypted.
|
|
42
|
+
* @returns A wire-format string containing the encrypted ciphertext and
|
|
43
|
+
* ephemeral public key information: `encrypted:v1:<base64>`.
|
|
44
|
+
*/
|
|
45
|
+
export function encryptEnvValue(plaintext, recipientPublicKeyHex, varName) {
|
|
46
|
+
if (!/^[a-fA-F0-9]+$/.test(recipientPublicKeyHex) || recipientPublicKeyHex.length !== PUBLIC_KEY_HEX_LENGTH) {
|
|
47
|
+
throw new Error(`Invalid ENVGAD_PUBLIC_KEY format (expected ${PUBLIC_KEY_HEX_LENGTH}-char hex-encoded SPKI DER, got ${recipientPublicKeyHex.length} chars)`);
|
|
48
|
+
}
|
|
49
|
+
const recipientSpki = Buffer.from(recipientPublicKeyHex, "hex");
|
|
50
|
+
const { publicKeyHex: ephPubHex, privateKeyHex: ephPrivHex } = generateKeyPair();
|
|
51
|
+
const ephSpki = Buffer.from(ephPubHex, "hex");
|
|
52
|
+
const ephPkcs8 = Buffer.from(ephPrivHex, "hex");
|
|
53
|
+
const sharedSecret = diffieHellman({
|
|
54
|
+
privateKey: createPrivateKey({ key: ephPkcs8, format: "der", type: "pkcs8" }),
|
|
55
|
+
publicKey: createPublicKey({ key: recipientSpki, format: "der", type: "spki" }),
|
|
56
|
+
});
|
|
57
|
+
const encKey = Buffer.from(hkdfSync("sha256", sharedSecret, HKDF_SALT, HKDF_INFO, 32));
|
|
58
|
+
const nonce = randomBytes(NONCE_LENGTH);
|
|
59
|
+
const aad = Buffer.from(`${AAD_PREFIX}${varName}`);
|
|
60
|
+
const cipher = chacha20poly1305(encKey, nonce, aad);
|
|
61
|
+
// encrypt() returns ciphertext || 16-byte auth tag concatenated
|
|
62
|
+
const encrypted = cipher.encrypt(Buffer.from(plaintext, "utf8"));
|
|
63
|
+
const rawEphPubKey = ephSpki.subarray(SPKI_PREFIX.length);
|
|
64
|
+
const payload = Buffer.concat([rawEphPubKey, nonce, encrypted]);
|
|
65
|
+
return `${PROTOCOL_PREFIX}${ENCRYPTION_VERSION}:${payload.toString("base64")}`;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Decrypt a wire-format ciphertext produced by {@link encryptEnvValue}.
|
|
69
|
+
*
|
|
70
|
+
* @param token - Wire-format string: `encrypted:v1:<base64>`.
|
|
71
|
+
* @param privateKeyHex - Hex-encoded PKCS8 DER of the recipient's private key (from ENVGAD_PRIVATE_KEY).
|
|
72
|
+
* @param varName - Schema variable name, must match the one used during encryption (AAD check).
|
|
73
|
+
* @returns Decrypted plaintext string.
|
|
74
|
+
* @throws {@link DecryptionFailedError} if the key is wrong, ciphertext is tampered, or AAD mismatches.
|
|
75
|
+
*/
|
|
76
|
+
export function decryptEnvValue(token, privateKeyHex, varName) {
|
|
77
|
+
const prefix = `${PROTOCOL_PREFIX}${ENCRYPTION_VERSION}:`;
|
|
78
|
+
if (!token.startsWith(prefix)) {
|
|
79
|
+
const versionMatch = token.match(/^encrypted:(\w+):/);
|
|
80
|
+
if (versionMatch) {
|
|
81
|
+
throw new Error(`Unsupported encryption version: ${versionMatch[1]}. ` +
|
|
82
|
+
`This version of dotenv-gad supports: ${ENCRYPTION_VERSION}`);
|
|
83
|
+
}
|
|
84
|
+
throw new Error("Invalid encrypted value format");
|
|
85
|
+
}
|
|
86
|
+
let payload;
|
|
87
|
+
try {
|
|
88
|
+
payload = Buffer.from(token.slice(prefix.length), "base64");
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw new Error("Invalid base64 encoding in encrypted value");
|
|
92
|
+
}
|
|
93
|
+
const minLength = RAW_KEY_LENGTH + NONCE_LENGTH + AUTH_TAG_LENGTH;
|
|
94
|
+
if (payload.length < minLength) {
|
|
95
|
+
throw new Error(`Encrypted value too short: ${payload.length} bytes (minimum: ${minLength})`);
|
|
96
|
+
}
|
|
97
|
+
const rawEphPubKey = payload.subarray(0, RAW_KEY_LENGTH);
|
|
98
|
+
const nonce = payload.subarray(RAW_KEY_LENGTH, RAW_KEY_LENGTH + NONCE_LENGTH);
|
|
99
|
+
// rest = ciphertext || 16-byte auth tag (as produced by @noble/ciphers encrypt)
|
|
100
|
+
const encrypted = payload.subarray(RAW_KEY_LENGTH + NONCE_LENGTH);
|
|
101
|
+
const ephSpki = Buffer.concat([SPKI_PREFIX, rawEphPubKey]);
|
|
102
|
+
// ECDH: recipient private × ephemeral public → shared secret
|
|
103
|
+
const pkcs8 = Buffer.from(privateKeyHex, "hex");
|
|
104
|
+
const sharedSecret = diffieHellman({
|
|
105
|
+
privateKey: createPrivateKey({ key: pkcs8, format: "der", type: "pkcs8" }),
|
|
106
|
+
publicKey: createPublicKey({ key: ephSpki, format: "der", type: "spki" }),
|
|
107
|
+
});
|
|
108
|
+
// HKDF-SHA256: same derivation as encryption
|
|
109
|
+
const encKey = Buffer.from(hkdfSync("sha256", sharedSecret, HKDF_SALT, HKDF_INFO, 32));
|
|
110
|
+
const aad = Buffer.from(`${AAD_PREFIX}${varName}`);
|
|
111
|
+
const decipher = chacha20poly1305(encKey, nonce, aad);
|
|
112
|
+
try {
|
|
113
|
+
const plaintext = decipher.decrypt(encrypted);
|
|
114
|
+
return Buffer.from(plaintext).toString("utf8");
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
throw new DecryptionFailedError(varName);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Returns true if the given value starts with the encrypted value
|
|
122
|
+
* prefix, and false otherwise.
|
|
123
|
+
*
|
|
124
|
+
* The encrypted value prefix is in the format of
|
|
125
|
+
* `encrypted:v1:<base64-encoded-payload>`.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} value the value to check
|
|
128
|
+
* @returns {boolean} true if the value is an encrypted value, false otherwise
|
|
129
|
+
*/
|
|
130
|
+
export function isEncryptedValue(value) {
|
|
131
|
+
return value.startsWith(`${PROTOCOL_PREFIX}${ENCRYPTION_VERSION}:`);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Loads the ENVGAD_PRIVATE_KEY value from the given path (default: `.env.keys`)
|
|
135
|
+
* or the ENVGAD_PRIVATE_KEY environment variable if present.
|
|
136
|
+
* Returns the hex-encoded DER value of the private key if found, or null otherwise.
|
|
137
|
+
* @param {Object} [options] Optional options.
|
|
138
|
+
* @param {string} [options.keysPath] Path to the `.env.keys` file (default: `.env.keys`).
|
|
139
|
+
* @returns {string | null} Hex-encoded DER value of the private key if found, or null otherwise.
|
|
140
|
+
*/
|
|
141
|
+
export function loadPrivateKey(options = {}) {
|
|
142
|
+
const keysPath = options.keysPath ?? ".env.keys";
|
|
143
|
+
if (existsSync(keysPath)) {
|
|
144
|
+
const content = readFileSync(keysPath, "utf8");
|
|
145
|
+
const match = content.match(/^ENVGAD_PRIVATE_KEY=([a-fA-F0-9]+)/m);
|
|
146
|
+
if (match) {
|
|
147
|
+
const key = match[1];
|
|
148
|
+
if (key.length !== PRIVATE_KEY_HEX_LENGTH) {
|
|
149
|
+
throw new Error(`Invalid ENVGAD_PRIVATE_KEY in ${keysPath}: expected ${PRIVATE_KEY_HEX_LENGTH}-char hex-encoded PKCS8 DER, got ${key.length} chars`);
|
|
150
|
+
}
|
|
151
|
+
return key;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const runtimeEnv = getEnv();
|
|
155
|
+
const envKey = runtimeEnv.ENVGAD_PRIVATE_KEY;
|
|
156
|
+
if (envKey) {
|
|
157
|
+
// Remove the key from environment after reading for security
|
|
158
|
+
delete runtimeEnv.ENVGAD_PRIVATE_KEY;
|
|
159
|
+
if (!/^[a-fA-F0-9]+$/.test(envKey) || envKey.length !== PRIVATE_KEY_HEX_LENGTH) {
|
|
160
|
+
throw new Error(`Invalid ENVGAD_PRIVATE_KEY format (expected ${PRIVATE_KEY_HEX_LENGTH}-char hex-encoded PKCS8 DER, got ${envKey.length} chars)`);
|
|
161
|
+
}
|
|
162
|
+
return envKey;
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
package/dist/errors.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const kValidationError = Symbol.for("dotenv-gad.EnvValidationError");
|
|
2
|
+
const kAggregateError = Symbol.for("dotenv-gad.EnvAggregateError");
|
|
1
3
|
export class EnvValidationError extends Error {
|
|
2
4
|
key;
|
|
3
5
|
message;
|
|
@@ -8,16 +10,50 @@ export class EnvValidationError extends Error {
|
|
|
8
10
|
this.message = message;
|
|
9
11
|
this.receiveValue = receiveValue;
|
|
10
12
|
this.name = "EnvValidationError";
|
|
13
|
+
Object.defineProperty(this, kValidationError, { value: true, enumerable: false });
|
|
14
|
+
}
|
|
15
|
+
static [Symbol.hasInstance](instance) {
|
|
16
|
+
return (typeof instance === "object" &&
|
|
17
|
+
instance !== null &&
|
|
18
|
+
Object.prototype.hasOwnProperty.call(instance, kValidationError));
|
|
11
19
|
}
|
|
12
20
|
}
|
|
21
|
+
Object.defineProperty(EnvValidationError, "name", {
|
|
22
|
+
value: "EnvValidationError",
|
|
23
|
+
configurable: true,
|
|
24
|
+
writable: false,
|
|
25
|
+
});
|
|
26
|
+
// WeakMap will store the full errors array completely off the instance,
|
|
27
|
+
// so Node.js inspect never sees it — no schema internals in logs.
|
|
28
|
+
const errorsMap = new WeakMap();
|
|
13
29
|
export class EnvAggregateError extends Error {
|
|
14
|
-
errors
|
|
30
|
+
get errors() {
|
|
31
|
+
return errorsMap.get(this);
|
|
32
|
+
}
|
|
15
33
|
constructor(errors, message) {
|
|
16
|
-
|
|
17
|
-
|
|
34
|
+
const summary = errors
|
|
35
|
+
.map((e) => {
|
|
36
|
+
let line = `\n - ${e.key}: ${e.message}`;
|
|
37
|
+
if (e.value !== undefined)
|
|
38
|
+
line += ` (received: ${JSON.stringify(e.value)})`;
|
|
39
|
+
if (e.rule?.docs)
|
|
40
|
+
line += `\n hint: ${e.rule.docs}`;
|
|
41
|
+
return line;
|
|
42
|
+
})
|
|
43
|
+
.join("");
|
|
44
|
+
super(message + summary);
|
|
18
45
|
this.name = "EnvAggregateError";
|
|
46
|
+
// Store full errors (including rule) off-instance, invisible to Node.js
|
|
47
|
+
// inspect but fully accessible via the .errors getter in catch blocks.
|
|
48
|
+
errorsMap.set(this, errors);
|
|
49
|
+
Object.defineProperty(this, kAggregateError, { value: true, enumerable: false });
|
|
19
50
|
Object.setPrototypeOf(this, EnvAggregateError.prototype);
|
|
20
51
|
}
|
|
52
|
+
static [Symbol.hasInstance](instance) {
|
|
53
|
+
return (typeof instance === "object" &&
|
|
54
|
+
instance !== null &&
|
|
55
|
+
Object.prototype.hasOwnProperty.call(instance, kAggregateError));
|
|
56
|
+
}
|
|
21
57
|
toString() {
|
|
22
58
|
const errorList = this.errors
|
|
23
59
|
.map((e) => {
|
|
@@ -29,8 +65,60 @@ export class EnvAggregateError extends Error {
|
|
|
29
65
|
return msg;
|
|
30
66
|
})
|
|
31
67
|
.join("\n");
|
|
32
|
-
return `${this.message}
|
|
68
|
+
return `${this.name}: ${this.message}\n${errorList}`;
|
|
33
69
|
}
|
|
34
70
|
}
|
|
71
|
+
Object.defineProperty(EnvAggregateError, "name", {
|
|
72
|
+
value: "EnvAggregateError",
|
|
73
|
+
configurable: true,
|
|
74
|
+
writable: false,
|
|
75
|
+
});
|
|
35
76
|
/** @deprecated Use `EnvAggregateError` instead — avoids shadowing the built-in `AggregateError`. */
|
|
36
77
|
export const AggregateError = EnvAggregateError;
|
|
78
|
+
/**
|
|
79
|
+
* Thrown when a required encryption or decryption key is not available.
|
|
80
|
+
* For encryption: ENVGAD_PUBLIC_KEY missing from .env.
|
|
81
|
+
* For decryption: ENVGAD_PRIVATE_KEY missing from .env.keys and environment.
|
|
82
|
+
*/
|
|
83
|
+
export class EncryptionKeyMissingError extends Error {
|
|
84
|
+
constructor(context) {
|
|
85
|
+
const message = context === "encryption"
|
|
86
|
+
? "Public key not found. Run: npx dotenv-gad keygen"
|
|
87
|
+
: "Private key not found. Obtain .env.keys from your team or set ENVGAD_PRIVATE_KEY env var. " +
|
|
88
|
+
"Run: npx dotenv-gad keygen to generate new keys.";
|
|
89
|
+
super(message);
|
|
90
|
+
this.name = "EncryptionKeyMissingError";
|
|
91
|
+
Object.setPrototypeOf(this, EncryptionKeyMissingError.prototype);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Thrown when ChaCha20-Poly1305 authenticated decryption fails.
|
|
96
|
+
* Common causes: wrong private key, corrupted ciphertext, or AAD mismatch
|
|
97
|
+
* (ciphertext copied from a different variable).
|
|
98
|
+
*/
|
|
99
|
+
export class DecryptionFailedError extends Error {
|
|
100
|
+
constructor(varName) {
|
|
101
|
+
super(`Decryption failed for "${varName}". Possible causes:\n` +
|
|
102
|
+
" - Wrong private key\n" +
|
|
103
|
+
" - Corrupted ciphertext\n" +
|
|
104
|
+
" - Ciphertext moved from a different variable (AAD mismatch)");
|
|
105
|
+
this.name = "DecryptionFailedError";
|
|
106
|
+
Object.setPrototypeOf(this, DecryptionFailedError.prototype);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Thrown when an encrypted/plaintext value is found but the schema says otherwise.
|
|
111
|
+
* When `shouldBeEncrypted = true`: schema has `encrypted: true` but value is plaintext.
|
|
112
|
+
* When `shouldBeEncrypted = false`: value starts with `encrypted:v1:` but schema lacks `encrypted: true`.
|
|
113
|
+
*/
|
|
114
|
+
export class EncryptedFieldMismatchError extends Error {
|
|
115
|
+
constructor(varName, shouldBeEncrypted) {
|
|
116
|
+
const message = shouldBeEncrypted
|
|
117
|
+
? `"${varName}" must be encrypted but received a plaintext value. ` +
|
|
118
|
+
"Run: npx dotenv-gad encrypt"
|
|
119
|
+
: `"${varName}" has an encrypted value but schema does not declare encrypted: true`;
|
|
120
|
+
super(message);
|
|
121
|
+
this.name = "EncryptedFieldMismatchError";
|
|
122
|
+
Object.setPrototypeOf(this, EncryptedFieldMismatchError.prototype);
|
|
123
|
+
}
|
|
124
|
+
}
|