@stacksjs/env 0.75.256 → 0.75.257

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 CHANGED
@@ -125,6 +125,33 @@ writes are `encrypted:v2:<base64url>`, and legacy public keys are rejected. Run
125
125
  `buddy env:rotate` to decrypt legacy values in memory, generate a version 2 key,
126
126
  and replace the encrypted file without writing plaintext to disk.
127
127
 
128
+ `isValidPublicKey(value)` validates canonical current-format X25519 public keys
129
+ through the same parser used by encryption. It returns a boolean without
130
+ throwing or exposing parser details. Private keys, legacy headers, other key
131
+ algorithms, and noncanonical/trailing data return `false`. Legacy decryption and
132
+ migration remain available.
133
+
134
+ `publicKeyMatchesPrivateKey(publicKey, privateKey)` validates the relationship
135
+ between current-format keys and returns only a boolean. A readable encrypted
136
+ value does not establish that its public header names the same private key.
137
+ `env:set` and `env:encrypt` reject a mismatch with an available private key
138
+ before changing ciphertext; encryption using only a public key remains supported.
139
+
140
+ `resolveEnvPrivateKey({ file, keysFile, cwd })` is the CLI/file-operation resolver
141
+ used by `getEnv`, encrypted writes, and strict Buddy checks. It selects the
142
+ target-scoped local key, target-scoped process key, generic local key, then
143
+ generic process key. `keysFile` defaults to `.env.keys` relative to `cwd`.
144
+ The cached runtime `resolvePrivateKey` retains its separate process-first
145
+ override policy for running applications.
146
+
147
+ `buddy env:check --strict` requires valid public metadata in committed env files
148
+ and rejects private-key assignments even if their values are encrypted. Legacy
149
+ headers require `buddy env:rotate`; a public-looking variable name alone does
150
+ not establish that its contents are safe to commit. Non-strict scanning retains
151
+ its existing compatibility behavior. Strict checks also reject a valid public
152
+ header that does not match the configured private half. Restore matching
153
+ metadata or use `buddy env:rotate` to migrate the existing readable values.
154
+
128
155
  This feature is not a complete secret-management system. It has not yet passed
129
156
  the independent review required by [Stacks issue #2058](https://github.com/stacksjs/stacks/issues/2058),
130
157
  so do not treat it as a production security boundary without your own review.
@@ -175,8 +202,18 @@ buddy env:set PUBLIC_URL "https://example.com" --plain
175
202
 
176
203
  # Set in specific file
177
204
  buddy env:set API_KEY "value" --file .env.production
205
+
206
+ # Read a secret from a file without putting it in the process arguments
207
+ buddy env:set APP_STORE_CONNECT_PRIVATE_KEY --stdin --file .env.production < /secure/AuthKey.p8
178
208
  ```
179
209
 
210
+ `--stdin` preserves the complete input, including whitespace, newlines, and an
211
+ empty value. It cannot be combined with a positional value and refuses an
212
+ interactive terminal. Values are encrypted by default; command output reports
213
+ the key name and target file without echoing the input. New and updated private
214
+ key files are owner-only (`0600`) and replaced atomically. Existing key files are
215
+ also restricted when reused for encryption.
216
+
180
217
  ### Manage Keypairs
181
218
 
182
219
  ```bash
package/dist/cli.d.ts CHANGED
@@ -67,6 +67,12 @@ export declare function envKeyNames(file?: string): { publicKeyName: string, pri
67
67
  export declare function envPublicKeyNames(file?: string): string[];
68
68
  /** Likewise for the private half. */
69
69
  export declare function envPrivateKeyNames(file?: string): string[];
70
+ /**
71
+ * Resolve a CLI/file operation's private key. Scoped keys precede generic
72
+ * fallbacks; the keys file precedes the process within each scope. Runtime
73
+ * process overrides use their separate cached resolvePrivateKey policy.
74
+ */
75
+ export declare function resolveEnvPrivateKey(options?: { file?: string, keysFile?: string, cwd?: string }): string | undefined;
70
76
  /**
71
77
  * Does this file already hold ciphertext, under any key name?
72
78
  *
package/dist/cli.js CHANGED
@@ -1,29 +1,29 @@
1
- import{existsSync,readFileSync,renameSync,rmSync,writeFileSync}from"node:fs";import{basename,dirname,resolve}from"node:path";import{decryptValue,encryptValue,generateKeypair}from"./crypto";import{parse}from"./parser";export function resolveEnvFile(file,environment){if(file)return file;const name=String(environment??"").trim();if(!name||name==="development"||name==="dev"||name==="local")return"";if(!/^[\w.-]+$/.test(name))return"";return`.env.${name}`}function inEncryptScope(key,options){return(!options.key||key.includes(options.key))&&(!options.excludeKey||!key.includes(options.excludeKey))}function encryptableKeys(envContent,options){const keys=[];for(const line of envContent.split(`
1
+ import{randomUUID}from"node:crypto";import{chmodSync,existsSync,readFileSync,renameSync,rmSync,writeFileSync}from"node:fs";import{basename,dirname,resolve}from"node:path";import{decryptValue,encryptValue,generateKeypair,publicKeyMatchesPrivateKey}from"./crypto";import{parse}from"./parser";function writePrivateKeys(path,content){const temporary=resolve(dirname(path),`.${basename(path)}.${randomUUID()}.tmp`);try{writeFileSync(temporary,content,{encoding:"utf8",mode:384,flag:"wx"});chmodSync(temporary,384);renameSync(temporary,path)}finally{rmSync(temporary,{force:!0})}}export function resolveEnvFile(file,environment){if(file)return file;const name=String(environment??"").trim();if(!name||name==="development"||name==="dev"||name==="local")return"";if(!/^[\w.-]+$/.test(name))return"";return`.env.${name}`}function inEncryptScope(key,options){return(!options.key||key.includes(options.key))&&(!options.excludeKey||!key.includes(options.excludeKey))}function encryptableKeys(envContent,options){const keys=[];for(const line of envContent.split(`
2
2
  `)){const trimmed=line.trim();if(!trimmed||trimmed.startsWith("#"))continue;if(trimmed.startsWith("DOTENV_PUBLIC_KEY"))continue;const match=trimmed.match(/^([^=]+)=(.*)$/);if(!match||match[1]===void 0)continue;const key=match[1].trim();if(inEncryptScope(key,options))keys.push(key)}return keys}function encryptedEnvContent(envContent,publicKey,publicKeyName,options){const encryptedLines=["#/-------------------[DOTENV_PUBLIC_KEY]--------------------/","#/ versioned X25519 encryption for .env files /","#/ [how it works](https://stacksjs.com/encryption) /","#/----------------------------------------------------------/",`${publicKeyName}="${publicKey}"`,""];let seenBody=!1;for(const line of envContent.split(`
3
3
  `)){const trimmed=line.trim();if(trimmed.startsWith("#/"))continue;if(trimmed.startsWith("DOTENV_PUBLIC_KEY"))continue;if(!trimmed&&!seenBody)continue;if(!trimmed||trimmed.startsWith("#")){seenBody=!0;encryptedLines.push(line);continue}seenBody=!0;const match=trimmed.match(/^([^=]+)=(.*)$/);if(!match||match[1]===void 0||match[2]===void 0){encryptedLines.push(line);continue}const key=match[1].trim();let value=match[2].trim();if(value.startsWith('"')&&value.endsWith('"')||value.startsWith("'")&&value.endsWith("'"))value=value.slice(1,-1);if(inEncryptScope(key,options)&&!value.startsWith("encrypted:"))value=encryptValue(value,publicKey);encryptedLines.push(`${key}="${value}"`)}return encryptedLines.join(`
4
- `)}export function envKeyNames(file){const suffix=((file||".env").split("/").pop()||"").replace(/^\.env\./,"").replace(/^\.env$/,"").toUpperCase();return{publicKeyName:suffix?`DOTENV_PUBLIC_KEY_${suffix}`:"DOTENV_PUBLIC_KEY",privateKeyName:suffix?`DOTENV_PRIVATE_KEY_${suffix}`:"DOTENV_PRIVATE_KEY"}}export function envPublicKeyNames(file){const{publicKeyName}=envKeyNames(file);return publicKeyName==="DOTENV_PUBLIC_KEY"?[publicKeyName]:[publicKeyName,"DOTENV_PUBLIC_KEY"]}export function envPrivateKeyNames(file){const{privateKeyName}=envKeyNames(file);return privateKeyName==="DOTENV_PRIVATE_KEY"?[privateKeyName]:[privateKeyName,"DOTENV_PRIVATE_KEY"]}export function envHasCiphertext(envContent){for(const line of envContent.split(`
4
+ `)}export function envKeyNames(file){const suffix=((file||".env").split("/").pop()||"").replace(/^\.env\./,"").replace(/^\.env$/,"").toUpperCase();return{publicKeyName:suffix?`DOTENV_PUBLIC_KEY_${suffix}`:"DOTENV_PUBLIC_KEY",privateKeyName:suffix?`DOTENV_PRIVATE_KEY_${suffix}`:"DOTENV_PRIVATE_KEY"}}export function envPublicKeyNames(file){const{publicKeyName}=envKeyNames(file);return publicKeyName==="DOTENV_PUBLIC_KEY"?[publicKeyName]:[publicKeyName,"DOTENV_PUBLIC_KEY"]}export function envPrivateKeyNames(file){const{privateKeyName}=envKeyNames(file);return privateKeyName==="DOTENV_PRIVATE_KEY"?[privateKeyName]:[privateKeyName,"DOTENV_PRIVATE_KEY"]}export function resolveEnvPrivateKey(options={}){const keysPath=resolve(options.cwd||process.cwd(),options.keysFile||".env.keys"),keys=existsSync(keysPath)?parse(readFileSync(keysPath,"utf8")).parsed:{};for(const name of envPrivateKeyNames(options.file)){const value=keys[name]||process.env[name];if(value)return value}return}function assertMatchingPrivateKey(publicKey,keysPath,file){const privateKey=resolveEnvPrivateKey({file,keysFile:keysPath});if(privateKey&&!publicKeyMatchesPrivateKey(publicKey,privateKey))throw Error("Encryption public key does not match the configured private key. Restore the matching keypair or run buddy env:rotate before writing encrypted values.")}export function envHasCiphertext(envContent){for(const line of envContent.split(`
5
5
  `)){const trimmed=line.trim();if(!trimmed||trimmed.startsWith("#"))continue;const match=trimmed.match(/^([^=]+)=(.*)$/);if(!match?.[1]||match[2]===void 0)continue;const value=match[2].trim().replace(/^["']|["']$/g,"");if(value.startsWith("encrypted:")||value.startsWith("enc:"))return!0}return!1}export function declaredEnvPublicKey(envContent,file){const names=envPublicKeyNames(file);for(const line of envContent.split(`
6
6
  `)){const trimmed=line.trim();if(!trimmed||trimmed.startsWith("#"))continue;const match=trimmed.match(/^([^=]+)=["']?([^"'\n]+)["']?/);if(!match?.[1]||!match[2])continue;const key=match[1].trim();if(names.includes(key))return{name:key,key:match[2]}}return}export function reusableEnvPublicKey(envContent,publicKeyName){let publicKey,hasCiphertext=!1;for(const line of envContent.split(`
7
- `)){const trimmed=line.trim();if(!trimmed||trimmed.startsWith("#"))continue;const match=trimmed.match(/^([^=]+)=(.*)$/);if(!match?.[1]||match[2]===void 0)continue;const key=match[1].trim(),value=match[2].trim().replace(/^["']|["']$/g,"");if(key===publicKeyName)publicKey=value;else if(value.startsWith("encrypted:")||value.startsWith("enc:"))hasCiphertext=!0}return publicKey&&hasCiphertext?publicKey:void 0}export function encryptEnv(options={}){const cwd=options.cwd||process.cwd(),envPath=resolve(cwd,options.file||".env"),keysPath=resolve(cwd,options.keysFile||".env.keys");if(!existsSync(envPath))return{success:!1,error:`File not found: ${envPath}`};try{let publicKey,privateKey;const existingPublicKey=reusableEnvPublicKey(readFileSync(envPath,"utf-8"),envKeyNames(options.file).publicKeyName);if(existingPublicKey)publicKey=existingPublicKey;else if(existsSync(keysPath)){const keysContent=readFileSync(keysPath,"utf-8"),{parsed}=parse(keysContent),{publicKeyName,privateKeyName}=envKeyNames(options.file);publicKey=parsed[publicKeyName]||"";privateKey=parsed[privateKeyName]||"";if(!publicKey||!privateKey){const keypair=generateKeypair();publicKey=keypair.publicKey;privateKey=keypair.privateKey;const newKeys=`
7
+ `)){const trimmed=line.trim();if(!trimmed||trimmed.startsWith("#"))continue;const match=trimmed.match(/^([^=]+)=(.*)$/);if(!match?.[1]||match[2]===void 0)continue;const key=match[1].trim(),value=match[2].trim().replace(/^["']|["']$/g,"");if(key===publicKeyName)publicKey=value;else if(value.startsWith("encrypted:")||value.startsWith("enc:"))hasCiphertext=!0}return publicKey&&hasCiphertext?publicKey:void 0}export function encryptEnv(options={}){const cwd=options.cwd||process.cwd(),envPath=resolve(cwd,options.file||".env"),keysPath=resolve(cwd,options.keysFile||".env.keys");if(!existsSync(envPath))return{success:!1,error:`File not found: ${envPath}`};try{if(existsSync(keysPath))chmodSync(keysPath,384);let publicKey,privateKey;const existingPublicKey=reusableEnvPublicKey(readFileSync(envPath,"utf-8"),envKeyNames(options.file).publicKeyName);if(existingPublicKey)publicKey=existingPublicKey;else if(existsSync(keysPath)){const keysContent=readFileSync(keysPath,"utf-8"),{parsed}=parse(keysContent),{publicKeyName,privateKeyName}=envKeyNames(options.file);publicKey=parsed[publicKeyName]||"";privateKey=parsed[privateKeyName]||"";if(!publicKey||!privateKey){const keypair=generateKeypair();publicKey=keypair.publicKey;privateKey=keypair.privateKey;const newKeys=`
8
8
  ${publicKeyName}="${publicKey}"
9
9
  ${privateKeyName}="${privateKey}"
10
- `;writeFileSync(keysPath,keysContent+newKeys,"utf-8")}}else{const keypair=generateKeypair();publicKey=keypair.publicKey;privateKey=keypair.privateKey;const{publicKeyName,privateKeyName}=envKeyNames(options.file),keysContent=`# .env.keys - Keep this file secure and never commit to source control
10
+ `;writePrivateKeys(keysPath,keysContent+newKeys)}}else{const keypair=generateKeypair();publicKey=keypair.publicKey;privateKey=keypair.privateKey;const{publicKeyName,privateKeyName}=envKeyNames(options.file),keysContent=`# .env.keys - Keep this file secure and never commit to source control
11
11
  ${publicKeyName}="${publicKey}"
12
12
  ${privateKeyName}="${privateKey}"
13
- `;writeFileSync(keysPath,keysContent,"utf-8")}const envContent=readFileSync(envPath,"utf-8"),output=encryptedEnvContent(envContent,publicKey,envKeyNames(options.file).publicKeyName,options);if(options.stdout)return{success:!0,output};writeFileSync(envPath,output,"utf-8");return{success:!0,output:`\u2714 encrypted (${options.file||".env"})
13
+ `;writePrivateKeys(keysPath,keysContent)}assertMatchingPrivateKey(publicKey,keysPath,options.file);const envContent=readFileSync(envPath,"utf-8"),output=encryptedEnvContent(envContent,publicKey,envKeyNames(options.file).publicKeyName,options);if(options.stdout)return{success:!0,output};writeFileSync(envPath,output,"utf-8");return{success:!0,output:`\u2714 encrypted (${options.file||".env"})
14
14
  \u2714 key added to ${options.keysFile||".env.keys"}`}}catch(error){return{success:!1,error:`Failed to encrypt: ${error instanceof Error?error.message:"Unknown error"}`}}}export function decryptEnv(options={}){const cwd=options.cwd||process.cwd(),envPath=resolve(cwd,options.file||".env"),keysPath=resolve(cwd,options.keysFile||".env.keys");if(!existsSync(envPath))return{success:!1,error:`File not found: ${envPath}`};if(!existsSync(keysPath))return{success:!1,error:`Keys file not found: ${keysPath}`};try{const keysContent=readFileSync(keysPath,"utf-8"),{parsed:keys}=parse(keysContent),env=((options.file||".env").split("/").pop()||"").replace(/^\.env\./,"").replace(/^\.env$/,"").toUpperCase(),privateKeyName=env?`DOTENV_PRIVATE_KEY_${env}`:"DOTENV_PRIVATE_KEY",privateKey=keys[privateKeyName];if(!privateKey)return{success:!1,error:`Private key not found: ${privateKeyName}`};const privateKeys=[privateKey];if(privateKeyName!=="DOTENV_PRIVATE_KEY"&&keys.DOTENV_PRIVATE_KEY&&keys.DOTENV_PRIVATE_KEY!==privateKey)privateKeys.push(keys.DOTENV_PRIVATE_KEY);const lines=readFileSync(envPath,"utf-8").split(`
15
15
  `),decryptedLines=[];for(const line of lines){const trimmed=line.trim();if(!trimmed||trimmed.startsWith("#")){decryptedLines.push(line);continue}if(trimmed.startsWith("DOTENV_PUBLIC_KEY"))continue;const match=trimmed.match(/^([^=]+)=(.*)$/);if(!match||match[1]===void 0||match[2]===void 0){decryptedLines.push(line);continue}const key=match[1].trim();let value=match[2].trim();if(value.startsWith('"')&&value.endsWith('"')||value.startsWith("'")&&value.endsWith("'"))value=value.slice(1,-1);let shouldDecrypt=value.startsWith("encrypted:");if(options.key&&!key.includes(options.key))shouldDecrypt=!1;if(shouldDecrypt){let decrypted,lastError;for(const candidate of privateKeys)try{decrypted=decryptValue(value,candidate);break}catch(error){lastError=error}if(decrypted===void 0)throw lastError instanceof Error?lastError:Error(`Unable to decrypt ${key}`);value=decrypted}decryptedLines.push(`${key}="${value}"`)}const output=decryptedLines.join(`
16
- `);if(options.stdout)return{success:!0,output};writeFileSync(envPath,output,"utf-8");return{success:!0,output:`\u2714 decrypted (${options.file||".env"})`}}catch(error){return{success:!1,error:`Failed to decrypt: ${error instanceof Error?error.message:"Unknown error"}`}}}export function setEnv(key,value,options={}){const cwd=options.cwd||process.cwd(),envPath=resolve(cwd,options.file||".env"),keysPath=options.keysFile?resolve(cwd,options.keysFile):resolve(dirname(envPath),".env.keys");try{let content="";if(existsSync(envPath))content=readFileSync(envPath,"utf-8");const lines=content.split(`
16
+ `);if(options.stdout)return{success:!0,output};writeFileSync(envPath,output,"utf-8");return{success:!0,output:`\u2714 decrypted (${options.file||".env"})`}}catch(error){return{success:!1,error:`Failed to decrypt: ${error instanceof Error?error.message:"Unknown error"}`}}}export function setEnv(key,value,options={}){const cwd=options.cwd||process.cwd(),envPath=resolve(cwd,options.file||".env"),keysPath=options.keysFile?resolve(cwd,options.keysFile):resolve(dirname(envPath),".env.keys");try{if(!options.plain&&existsSync(keysPath))chmodSync(keysPath,384);let content="";if(existsSync(envPath))content=readFileSync(envPath,"utf-8");const lines=content.split(`
17
17
  `);let found=!1,publicKey;const env=((options.file||".env").split("/").pop()||"").replace(/^\.env\./,"").replace(/^\.env$/,"").toUpperCase(),publicKeyName=env?`DOTENV_PUBLIC_KEY_${env}`:"DOTENV_PUBLIC_KEY",privateKeyName=env?`DOTENV_PRIVATE_KEY_${env}`:"DOTENV_PRIVATE_KEY",declared=declaredEnvPublicKey(content,options.file),declaredName=declared?.name??publicKeyName;publicKey=declared?.key;if(publicKey){let hasMatchingPrivateKey=!1;if(existsSync(keysPath)){const keysContent=readFileSync(keysPath,"utf-8"),{parsed:keys}=parse(keysContent);hasMatchingPrivateKey=envPrivateKeyNames(options.file).some((name)=>Boolean(keys[name]))}const privateKeyInEnv=envPrivateKeyNames(options.file).some((name)=>Boolean(process.env[name]));if(!hasMatchingPrivateKey&&!privateKeyInEnv&&!envHasCiphertext(content))publicKey=void 0}if(!options.plain&&!publicKey&&envHasCiphertext(content))return{success:!1,error:`${options.file||".env"} already contains encrypted values, but no key that can be used to add another was found.
18
18
  Generating one would leave every existing encrypted value unreadable.
19
19
  Provide the private key as ${envPrivateKeyNames(options.file)[0]} in the environment, restore ${keysPath}, or use --plain to write this value unencrypted.`};if(!options.plain&&!publicKey){const keypair=generateKeypair();publicKey=keypair.publicKey;let keysContent="";if(existsSync(keysPath))keysContent=readFileSync(keysPath,"utf-8");keysContent+=`
20
20
  ${publicKeyName}="${keypair.publicKey}"
21
21
  ${privateKeyName}="${keypair.privateKey}"
22
- `;writeFileSync(keysPath,keysContent,"utf-8");const staleNames=envPublicKeyNames(options.file);for(let i=lines.length-1;i>=0;i--){const lineEntry=lines[i];if(lineEntry!==void 0&&staleNames.some((name)=>lineEntry.trim().startsWith(`${name}=`)))lines.splice(i,1)}lines.unshift(`${declaredName}="${publicKey}"`)}let finalValue=value;if(!options.plain&&publicKey)finalValue=encryptValue(value,publicKey);for(let i=0;i<lines.length;i++){const lineEntry=lines[i];if(lineEntry===void 0)continue;if(lineEntry.trim().startsWith(`${key}=`)){lines[i]=`${key}="${finalValue}"`;found=!0;break}}if(!found)lines.push(`${key}="${finalValue}"`);const output=lines.join(`
23
- `);writeFileSync(envPath,output,"utf-8");return{success:!0,output:`set ${key}${options.plain?"":" with encryption"} (${options.file||".env"})`}}catch(error){return{success:!1,error:`Failed to set: ${error instanceof Error?error.message:"Unknown error"}`}}}export function getEnv(key,options={}){const cwd=options.cwd||process.cwd(),envPath=resolve(cwd,options.file||".env");if(!existsSync(envPath))return{success:!1,error:`File not found: ${envPath}`};try{let privateKey;const keysPath=resolve(cwd,options.keysFile||".env.keys"),env=((options.file||".env").split("/").pop()||"").replace(/^\.env\./,"").replace(/^\.env$/,"").toUpperCase(),privateKeyName=env?`DOTENV_PRIVATE_KEY_${env}`:"DOTENV_PRIVATE_KEY";if(existsSync(keysPath)){const keysContent=readFileSync(keysPath,"utf-8"),{parsed:keys}=parse(keysContent);privateKey=keys[privateKeyName]}if(!privateKey)privateKey=process.env[privateKeyName];const envContent=readFileSync(envPath,"utf-8"),{parsed,skippedEncrypted}=parse(envContent,{privateKey}),unreadable=key&&!options.all?skippedEncrypted.filter((name)=>name===key):skippedEncrypted;if(unreadable.length>0)return{success:!1,error:`Could not decrypt ${unreadable.join(", ")}. Restore ${privateKeyName} before reading this environment.`};if(key&&!options.all){const value=parsed[key];if(value===void 0)return{success:!1,error:`Key not found: ${key}`};return{success:!0,output:value}}const result=options.all?{...process.env,...parsed}:parsed;let output;switch(options.format){case"shell":output=Object.entries(result).map(([k,v])=>`${k}=${v}`).join(" ");break;case"eval":output=Object.entries(result).map(([k,v])=>`${k}="${v}"`).join(`
22
+ `;writePrivateKeys(keysPath,keysContent);const staleNames=envPublicKeyNames(options.file);for(let i=lines.length-1;i>=0;i--){const lineEntry=lines[i];if(lineEntry!==void 0&&staleNames.some((name)=>lineEntry.trim().startsWith(`${name}=`)))lines.splice(i,1)}lines.unshift(`${declaredName}="${publicKey}"`)}let finalValue=value;if(!options.plain&&publicKey){assertMatchingPrivateKey(publicKey,keysPath,options.file);finalValue=encryptValue(value,publicKey)}for(let i=0;i<lines.length;i++){const lineEntry=lines[i];if(lineEntry===void 0)continue;if(lineEntry.trim().startsWith(`${key}=`)){lines[i]=`${key}="${finalValue}"`;found=!0;break}}if(!found)lines.push(`${key}="${finalValue}"`);const output=lines.join(`
23
+ `);writeFileSync(envPath,output,"utf-8");return{success:!0,output:`set ${key}${options.plain?"":" with encryption"} (${options.file||".env"})`}}catch(error){return{success:!1,error:`Failed to set: ${error instanceof Error?error.message:"Unknown error"}`}}}export function getEnv(key,options={}){const cwd=options.cwd||process.cwd(),envPath=resolve(cwd,options.file||".env");if(!existsSync(envPath))return{success:!1,error:`File not found: ${envPath}`};try{const privateKey=resolveEnvPrivateKey(options),{privateKeyName}=envKeyNames(options.file),envContent=readFileSync(envPath,"utf-8"),{parsed,skippedEncrypted}=parse(envContent,{privateKey}),unreadable=key&&!options.all?skippedEncrypted.filter((name)=>name===key):skippedEncrypted;if(unreadable.length>0)return{success:!1,error:`Could not decrypt ${unreadable.join(", ")}. Restore ${privateKeyName} before reading this environment.`};if(key&&!options.all){const value=parsed[key];if(value===void 0)return{success:!1,error:`Key not found: ${key}`};return{success:!0,output:value}}const result=options.all?{...process.env,...parsed}:parsed;let output;switch(options.format){case"shell":output=Object.entries(result).map(([k,v])=>`${k}=${v}`).join(" ");break;case"eval":output=Object.entries(result).map(([k,v])=>`${k}="${v}"`).join(`
24
24
  `);break;case"json":default:output=options.prettyPrint?JSON.stringify(result,null,2):JSON.stringify(result);break}return{success:!0,output}}catch(error){return{success:!1,error:`Failed to get: ${error instanceof Error?error.message:"Unknown error"}`}}}export function getKeypair(keyName,options={}){const cwd=options.cwd||process.cwd(),keysPath=resolve(cwd,options.keysFile||".env.keys");if(!existsSync(keysPath))return{success:!1,error:`Keys file not found: ${keysPath}`};try{const keysContent=readFileSync(keysPath,"utf-8"),{parsed}=parse(keysContent),env=((options.file||".env").split("/").pop()||"").replace(/^\.env\./,"").replace(/^\.env$/,"").toUpperCase(),publicKeyName=env?`DOTENV_PUBLIC_KEY_${env}`:"DOTENV_PUBLIC_KEY",privateKeyName=env?`DOTENV_PRIVATE_KEY_${env}`:"DOTENV_PRIVATE_KEY";if(keyName){const value=parsed[keyName];if(!value)return{success:!1,error:`Key not found: ${keyName}`};return{success:!0,output:value}}const keypair={[publicKeyName]:parsed[publicKeyName],[privateKeyName]:parsed[privateKeyName]};return{success:!0,output:options.format==="shell"?`${publicKeyName}=${keypair[publicKeyName]} ${privateKeyName}=${keypair[privateKeyName]}`:JSON.stringify(keypair)}}catch(error){return{success:!1,error:`Failed to get keypair: ${error instanceof Error?error.message:"Unknown error"}`}}}function rotatedKeysContent(keysContent,keypair,publicKeyName,privateKeyName){const lines=keysContent.split(`
25
25
  `);let replacedPublic=!1,replacedPrivate=!1;for(let i=0;i<lines.length;i++){const entry=lines[i];if(entry===void 0)continue;if(entry.startsWith(`${publicKeyName}=`)){lines[i]=`${publicKeyName}="${keypair.publicKey}"`;replacedPublic=!0}else if(entry.startsWith(`${privateKeyName}=`)){lines[i]=`${privateKeyName}="${keypair.privateKey}"`;replacedPrivate=!0}}if(!replacedPublic)lines.push(`${publicKeyName}="${keypair.publicKey}"`);if(!replacedPrivate)lines.push(`${privateKeyName}="${keypair.privateKey}"`);return`${lines.join(`
26
26
  `).replace(/\n+$/,"")}
27
27
  `}export function rotateKeypair(options={}){const decryptResult=decryptEnv({file:options.file,keysFile:options.keysFile,stdout:!0,cwd:options.cwd});if(!decryptResult.success)return decryptResult;if(decryptResult.output===void 0)return{success:!1,error:"Rotation produced no decrypted content"};const cwd=options.cwd||process.cwd(),envFileName=options.file||".env",keysFileName=options.keysFile||".env.keys",envPath=resolve(cwd,envFileName),keysPath=resolve(cwd,keysFileName),{publicKeyName,privateKeyName}=envKeyNames(options.file);if(options.dryRun){const values=encryptableKeys(decryptResult.output,options);return{success:!0,output:["Dry run: nothing was written.",`${envFileName}: ${values.length} value${values.length===1?"":"s"} would be re-encrypted under a new ${publicKeyName}.`,`${keysFileName}: ${publicKeyName} and ${privateKeyName} would be replaced.`,`The current ${privateKeyName} decrypts nothing once that happens, so keep a copy until the rotation is verified.`].join(`
28
28
  `)}}const keypair=generateKeypair(),nextEnv=encryptedEnvContent(decryptResult.output,keypair.publicKey,publicKeyName,options);if(options.stdout)return{success:!0,output:nextEnv,notice:[`Nothing was written. To adopt the output above, put this keypair in ${keysFileName}:`,"",`${publicKeyName}="${keypair.publicKey}"`,`${privateKeyName}="${keypair.privateKey}"`,"","Without it that output cannot be decrypted. Run without --stdout to have both applied together."].join(`
29
- `)};const originalEnv=readFileSync(envPath),originalKeys=readFileSync(keysPath),nextKeys=rotatedKeysContent(originalKeys.toString("utf8"),keypair,publicKeyName,privateKeyName),token=`${process.pid}-${Date.now()}`,envTemp=resolve(dirname(envPath),`.${basename(envPath)}.${token}.rotate`),keysTemp=resolve(dirname(keysPath),`.${basename(keysPath)}.${token}.rotate`);try{writeFileSync(envTemp,nextEnv,{encoding:"utf8",mode:384});writeFileSync(keysTemp,nextKeys,{encoding:"utf8",mode:384});renameSync(envTemp,envPath);try{renameSync(keysTemp,keysPath)}catch(error){writeFileSync(envPath,originalEnv);throw error}return{success:!0,output:`\u2714 rotated (${envFileName}) to encrypted:v2`}}catch(error){if(!existsSync(keysPath))writeFileSync(keysPath,originalKeys,{mode:384});return{success:!1,error:`Failed to rotate without exposing plaintext: ${error instanceof Error?error.message:"Unknown error"}`}}finally{rmSync(envTemp,{force:!0});rmSync(keysTemp,{force:!0})}}
29
+ `)};const originalEnv=readFileSync(envPath),originalKeys=readFileSync(keysPath),nextKeys=rotatedKeysContent(originalKeys.toString("utf8"),keypair,publicKeyName,privateKeyName),token=`${process.pid}-${Date.now()}`,envTemp=resolve(dirname(envPath),`.${basename(envPath)}.${token}.rotate`),keysTemp=resolve(dirname(keysPath),`.${basename(keysPath)}.${token}.rotate`);try{writeFileSync(envTemp,nextEnv,{encoding:"utf8",mode:384});writeFileSync(keysTemp,nextKeys,{encoding:"utf8",mode:384});renameSync(envTemp,envPath);try{renameSync(keysTemp,keysPath)}catch(error){writeFileSync(envPath,originalEnv);throw error}return{success:!0,output:`\u2714 rotated (${envFileName}) to encrypted:v2`}}catch(error){if(!existsSync(keysPath))writePrivateKeys(keysPath,originalKeys);return{success:!1,error:`Failed to rotate without exposing plaintext: ${error instanceof Error?error.message:"Unknown error"}`}}finally{rmSync(envTemp,{force:!0});rmSync(keysTemp,{force:!0})}}
package/dist/crypto.d.ts CHANGED
@@ -1,5 +1,15 @@
1
1
  export declare function aesEncrypt(plaintext: string, key: Buffer): { ciphertext: string, iv: string, authTag: string };
2
2
  export declare function aesDecrypt(ciphertext: string, key: Buffer, iv: string, authTag: string): string;
3
+ /**
4
+ * Whether a value is a canonical current-format X25519 public key.
5
+ *
6
+ * Returns only a boolean and never exposes parser errors or key material.
7
+ * Legacy headers are not validated as safe public metadata by this API;
8
+ * their existing decryption and `env:rotate` migration remain supported.
9
+ */
10
+ export declare function isValidPublicKey(value: unknown): value is string;
11
+ /** Validate a current public/private key relationship without exposing either key. */
12
+ export declare function publicKeyMatchesPrivateKey(publicKey: unknown, privateKey: unknown): boolean;
3
13
  export declare function generateKeypair(): { publicKey: string, privateKey: string };
4
14
  export declare function encryptValue(value: string, recipientPublicKey: string): string;
5
15
  export declare function isLegacyEncryptedValue(value: string): boolean;
package/dist/crypto.js CHANGED
@@ -1 +1 @@
1
- import{createCipheriv,createDecipheriv,createHash,createPrivateKey,createPublicKey,diffieHellman,generateKeyPairSync,hkdfSync,randomBytes}from"node:crypto";const PUBLIC_KEY_PREFIX="x25519-public:",PRIVATE_KEY_PREFIX="x25519-private:",ENVELOPE_PREFIX="encrypted:v2:",LEGACY_PREFIX="encrypted:",HKDF_INFO=Buffer.from("stacks-env:v2","utf8"),DECRYPTION_ERROR="Environment decryption failed: authentication or format error";function encryptionKey(key){if(key.length===32)return key;return createHash("sha256").update(key).digest()}export function aesEncrypt(plaintext,key){const iv=randomBytes(16),cipher=createCipheriv("aes-256-gcm",encryptionKey(key),iv);return{ciphertext:Buffer.concat([cipher.update(plaintext,"utf8"),cipher.final()]).toString("hex"),iv:iv.toString("hex"),authTag:cipher.getAuthTag().toString("hex")}}export function aesDecrypt(ciphertext,key,iv,authTag){try{const decipher=createDecipheriv("aes-256-gcm",encryptionKey(key),Buffer.from(iv,"hex"));decipher.setAuthTag(Buffer.from(authTag,"hex"));return Buffer.concat([decipher.update(Buffer.from(ciphertext,"hex")),decipher.final()]).toString("utf8")}catch{throw Error("Decryption failed: authentication or format error")}}function encode(data){return data.toString("base64url")}function decode(value,label,expectedLength){if(typeof value!=="string"||value.length>0&&!/^[A-Za-z0-9_-]+$/.test(value))throw Error(`${label} is not canonical base64url`);const decoded=Buffer.from(value,"base64url");if(encode(decoded)!==value)throw Error(`${label} is not canonical base64url`);if(expectedLength!==void 0&&decoded.length!==expectedLength)throw Error(`${label} has an invalid length`);return decoded}function parsePublicKey(value){if(!value.startsWith(PUBLIC_KEY_PREFIX))throw Error("Legacy or invalid public key. Run buddy env:rotate before creating new ciphertext.");const key=createPublicKey({key:decode(value.slice(PUBLIC_KEY_PREFIX.length),"public key"),format:"der",type:"spki"});if(key.asymmetricKeyType!=="x25519")throw Error("Public key is not X25519");return key}function parsePrivateKey(value){if(!value.startsWith(PRIVATE_KEY_PREFIX))throw Error("Private key is not a version 2 X25519 key");const key=createPrivateKey({key:decode(value.slice(PRIVATE_KEY_PREFIX.length),"private key"),format:"der",type:"pkcs8"});if(key.asymmetricKeyType!=="x25519")throw Error("Private key is not X25519");return key}function aad(envelope){return Buffer.from(`stacks-env:v2;${envelope.epk};${envelope.salt};${envelope.nonce}`,"utf8")}export function generateKeypair(){const{publicKey,privateKey}=generateKeyPairSync("x25519"),publicDer=publicKey.export({format:"der",type:"spki"}),privateDer=privateKey.export({format:"der",type:"pkcs8"});return{publicKey:`${PUBLIC_KEY_PREFIX}${encode(publicDer)}`,privateKey:`${PRIVATE_KEY_PREFIX}${encode(privateDer)}`}}export function encryptValue(value,recipientPublicKey){const recipient=parsePublicKey(recipientPublicKey),ephemeral=generateKeyPairSync("x25519"),epk=encode(ephemeral.publicKey.export({format:"der",type:"spki"})),salt=randomBytes(16),nonce=randomBytes(12),shared=diffieHellman({privateKey:ephemeral.privateKey,publicKey:recipient}),key=Buffer.from(hkdfSync("sha256",shared,salt,HKDF_INFO,32)),metadata={epk,salt:encode(salt),nonce:encode(nonce)};try{const cipher=createCipheriv("aes-256-gcm",key,nonce);cipher.setAAD(aad(metadata));const ciphertext=Buffer.concat([cipher.update(value,"utf8"),cipher.final()]),envelope={v:2,...metadata,ciphertext:encode(ciphertext),tag:encode(cipher.getAuthTag())};return`${ENVELOPE_PREFIX}${encode(Buffer.from(JSON.stringify(envelope),"utf8"))}`}finally{shared.fill(0);key.fill(0)}}function parseEnvelope(value){const serialized=decode(value.slice(ENVELOPE_PREFIX.length),"envelope").toString("utf8"),parsed=JSON.parse(serialized);if(!parsed||typeof parsed!=="object"||Array.isArray(parsed))throw Error("envelope is not an object");const keys=Object.keys(parsed).sort(),expected=["ciphertext","epk","nonce","salt","tag","v"];if(keys.length!==expected.length||keys.some((key,index)=>key!==expected[index]))throw Error("envelope fields are invalid");if(parsed.v!==2)throw Error("envelope version is unsupported");for(const field of["epk","salt","nonce","ciphertext","tag"])if(typeof parsed[field]!=="string")throw Error(`${field} is invalid`);return parsed}function decryptV2(encryptedValue,recipientPrivateKey){try{const envelope=parseEnvelope(encryptedValue),recipient=parsePrivateKey(recipientPrivateKey),ephemeral=createPublicKey({key:decode(envelope.epk,"ephemeral public key"),format:"der",type:"spki"});if(ephemeral.asymmetricKeyType!=="x25519")throw Error("ephemeral key is not X25519");const salt=decode(envelope.salt,"salt",16),nonce=decode(envelope.nonce,"nonce",12),ciphertext=decode(envelope.ciphertext,"ciphertext"),tag=decode(envelope.tag,"tag",16),shared=diffieHellman({privateKey:recipient,publicKey:ephemeral}),key=Buffer.from(hkdfSync("sha256",shared,salt,HKDF_INFO,32));try{const decipher=createDecipheriv("aes-256-gcm",key,nonce);decipher.setAAD(aad(envelope));decipher.setAuthTag(tag);return Buffer.concat([decipher.update(ciphertext),decipher.final()]).toString("utf8")}finally{shared.fill(0);key.fill(0)}}catch{throw Error(DECRYPTION_ERROR)}}function decryptLegacy(encryptedValue,privateKey){try{if(!/^[0-9a-f]{64}$/.test(privateKey))throw Error("invalid legacy private key");const data=Buffer.from(encryptedValue.slice(LEGACY_PREFIX.length),"base64");if(data.length<32)throw Error("invalid legacy payload");const publicKeyHash=createHash("sha256").update(Buffer.from(privateKey,"hex")).digest(),key=createHash("sha256").update(publicKeyHash).digest();return aesDecrypt(data.subarray(32).toString("hex"),key,data.subarray(0,16).toString("hex"),data.subarray(16,32).toString("hex"))}catch{throw Error(DECRYPTION_ERROR)}}export function isLegacyEncryptedValue(value){return value.startsWith(LEGACY_PREFIX)&&!value.startsWith(ENVELOPE_PREFIX)}export function decryptValue(encryptedValue,privateKey){if(!encryptedValue.startsWith(LEGACY_PREFIX))return encryptedValue;return encryptedValue.startsWith(ENVELOPE_PREFIX)?decryptV2(encryptedValue,privateKey):decryptLegacy(encryptedValue,privateKey)}export function migrateEncryptedValue(encryptedValue,oldPrivateKey,newPublicKey){return encryptValue(decryptValue(encryptedValue,oldPrivateKey),newPublicKey)}export function parseEnvFromKey(keyName){const match=keyName.match(/^DOTENV_PRIVATE_KEY_(.+)$/);return match&&match[1]?match[1].toLowerCase():""}export function getPrivateKey(env=""){return env?process.env[`DOTENV_PRIVATE_KEY_${env.toUpperCase()}`]:process.env.DOTENV_PRIVATE_KEY}
1
+ import{createCipheriv,createDecipheriv,createHash,createPrivateKey,createPublicKey,diffieHellman,generateKeyPairSync,hkdfSync,randomBytes}from"node:crypto";const PUBLIC_KEY_PREFIX="x25519-public:",PRIVATE_KEY_PREFIX="x25519-private:",ENVELOPE_PREFIX="encrypted:v2:",LEGACY_PREFIX="encrypted:",HKDF_INFO=Buffer.from("stacks-env:v2","utf8"),DECRYPTION_ERROR="Environment decryption failed: authentication or format error";function encryptionKey(key){if(key.length===32)return key;return createHash("sha256").update(key).digest()}export function aesEncrypt(plaintext,key){const iv=randomBytes(16),cipher=createCipheriv("aes-256-gcm",encryptionKey(key),iv);return{ciphertext:Buffer.concat([cipher.update(plaintext,"utf8"),cipher.final()]).toString("hex"),iv:iv.toString("hex"),authTag:cipher.getAuthTag().toString("hex")}}export function aesDecrypt(ciphertext,key,iv,authTag){try{const decipher=createDecipheriv("aes-256-gcm",encryptionKey(key),Buffer.from(iv,"hex"));decipher.setAuthTag(Buffer.from(authTag,"hex"));return Buffer.concat([decipher.update(Buffer.from(ciphertext,"hex")),decipher.final()]).toString("utf8")}catch{throw Error("Decryption failed: authentication or format error")}}function encode(data){return data.toString("base64url")}function decode(value,label,expectedLength){if(typeof value!=="string"||value.length>0&&!/^[A-Za-z0-9_-]+$/.test(value))throw Error(`${label} is not canonical base64url`);const decoded=Buffer.from(value,"base64url");if(encode(decoded)!==value)throw Error(`${label} is not canonical base64url`);if(expectedLength!==void 0&&decoded.length!==expectedLength)throw Error(`${label} has an invalid length`);return decoded}function parsePublicKey(value){if(!value.startsWith(PUBLIC_KEY_PREFIX))throw Error("Legacy or invalid public key. Run buddy env:rotate before creating new ciphertext.");const key=createPublicKey({key:decode(value.slice(PUBLIC_KEY_PREFIX.length),"public key"),format:"der",type:"spki"});if(key.asymmetricKeyType!=="x25519")throw Error("Public key is not X25519");return key}export function isValidPublicKey(value){if(typeof value!=="string")return!1;try{const key=parsePublicKey(value);return value===`${PUBLIC_KEY_PREFIX}${encode(key.export({format:"der",type:"spki"}))}`}catch{return!1}}function parsePrivateKey(value){if(!value.startsWith(PRIVATE_KEY_PREFIX))throw Error("Private key is not a version 2 X25519 key");const key=createPrivateKey({key:decode(value.slice(PRIVATE_KEY_PREFIX.length),"private key"),format:"der",type:"pkcs8"});if(key.asymmetricKeyType!=="x25519")throw Error("Private key is not X25519");return key}export function publicKeyMatchesPrivateKey(publicKey,privateKey){if(!isValidPublicKey(publicKey)||typeof privateKey!=="string")return!1;try{const derived=createPublicKey(parsePrivateKey(privateKey));return publicKey===`${PUBLIC_KEY_PREFIX}${encode(derived.export({format:"der",type:"spki"}))}`}catch{return!1}}function aad(envelope){return Buffer.from(`stacks-env:v2;${envelope.epk};${envelope.salt};${envelope.nonce}`,"utf8")}export function generateKeypair(){const{publicKey,privateKey}=generateKeyPairSync("x25519"),publicDer=publicKey.export({format:"der",type:"spki"}),privateDer=privateKey.export({format:"der",type:"pkcs8"});return{publicKey:`${PUBLIC_KEY_PREFIX}${encode(publicDer)}`,privateKey:`${PRIVATE_KEY_PREFIX}${encode(privateDer)}`}}export function encryptValue(value,recipientPublicKey){const recipient=parsePublicKey(recipientPublicKey),ephemeral=generateKeyPairSync("x25519"),epk=encode(ephemeral.publicKey.export({format:"der",type:"spki"})),salt=randomBytes(16),nonce=randomBytes(12),shared=diffieHellman({privateKey:ephemeral.privateKey,publicKey:recipient}),key=Buffer.from(hkdfSync("sha256",shared,salt,HKDF_INFO,32)),metadata={epk,salt:encode(salt),nonce:encode(nonce)};try{const cipher=createCipheriv("aes-256-gcm",key,nonce);cipher.setAAD(aad(metadata));const ciphertext=Buffer.concat([cipher.update(value,"utf8"),cipher.final()]),envelope={v:2,...metadata,ciphertext:encode(ciphertext),tag:encode(cipher.getAuthTag())};return`${ENVELOPE_PREFIX}${encode(Buffer.from(JSON.stringify(envelope),"utf8"))}`}finally{shared.fill(0);key.fill(0)}}function parseEnvelope(value){const serialized=decode(value.slice(ENVELOPE_PREFIX.length),"envelope").toString("utf8"),parsed=JSON.parse(serialized);if(!parsed||typeof parsed!=="object"||Array.isArray(parsed))throw Error("envelope is not an object");const keys=Object.keys(parsed).sort(),expected=["ciphertext","epk","nonce","salt","tag","v"];if(keys.length!==expected.length||keys.some((key,index)=>key!==expected[index]))throw Error("envelope fields are invalid");if(parsed.v!==2)throw Error("envelope version is unsupported");for(const field of["epk","salt","nonce","ciphertext","tag"])if(typeof parsed[field]!=="string")throw Error(`${field} is invalid`);return parsed}function decryptV2(encryptedValue,recipientPrivateKey){try{const envelope=parseEnvelope(encryptedValue),recipient=parsePrivateKey(recipientPrivateKey),ephemeral=createPublicKey({key:decode(envelope.epk,"ephemeral public key"),format:"der",type:"spki"});if(ephemeral.asymmetricKeyType!=="x25519")throw Error("ephemeral key is not X25519");const salt=decode(envelope.salt,"salt",16),nonce=decode(envelope.nonce,"nonce",12),ciphertext=decode(envelope.ciphertext,"ciphertext"),tag=decode(envelope.tag,"tag",16),shared=diffieHellman({privateKey:recipient,publicKey:ephemeral}),key=Buffer.from(hkdfSync("sha256",shared,salt,HKDF_INFO,32));try{const decipher=createDecipheriv("aes-256-gcm",key,nonce);decipher.setAAD(aad(envelope));decipher.setAuthTag(tag);return Buffer.concat([decipher.update(ciphertext),decipher.final()]).toString("utf8")}finally{shared.fill(0);key.fill(0)}}catch{throw Error(DECRYPTION_ERROR)}}function decryptLegacy(encryptedValue,privateKey){try{if(!/^[0-9a-f]{64}$/.test(privateKey))throw Error("invalid legacy private key");const data=Buffer.from(encryptedValue.slice(LEGACY_PREFIX.length),"base64");if(data.length<32)throw Error("invalid legacy payload");const publicKeyHash=createHash("sha256").update(Buffer.from(privateKey,"hex")).digest(),key=createHash("sha256").update(publicKeyHash).digest();return aesDecrypt(data.subarray(32).toString("hex"),key,data.subarray(0,16).toString("hex"),data.subarray(16,32).toString("hex"))}catch{throw Error(DECRYPTION_ERROR)}}export function isLegacyEncryptedValue(value){return value.startsWith(LEGACY_PREFIX)&&!value.startsWith(ENVELOPE_PREFIX)}export function decryptValue(encryptedValue,privateKey){if(!encryptedValue.startsWith(LEGACY_PREFIX))return encryptedValue;return encryptedValue.startsWith(ENVELOPE_PREFIX)?decryptV2(encryptedValue,privateKey):decryptLegacy(encryptedValue,privateKey)}export function migrateEncryptedValue(encryptedValue,oldPrivateKey,newPublicKey){return encryptValue(decryptValue(encryptedValue,oldPrivateKey),newPublicKey)}export function parseEnvFromKey(keyName){const match=keyName.match(/^DOTENV_PRIVATE_KEY_(.+)$/);return match&&match[1]?match[1].toLowerCase():""}export function getPrivateKey(env=""){return env?process.env[`DOTENV_PRIVATE_KEY_${env.toUpperCase()}`]:process.env.DOTENV_PRIVATE_KEY}
@@ -2,8 +2,8 @@ import { isEncryptedValue } from './encrypted-value';
2
2
  /**
3
3
  * Find values in a parsed env file that should have been encrypted.
4
4
  *
5
- * Returns key names, never values. Empty values, `${VAR}` expansions, dotenvx
6
- * key metadata and anything already encrypted are skipped.
5
+ * Returns key names, never values. Empty ordinary values, `${VAR}` expansions
6
+ * and encrypted values are skipped. Strict scans also validate key metadata.
7
7
  */
8
8
  export declare function plaintextSecrets(values: Record<string, string>, options?: PlaintextScanOptions): PlaintextFinding[];
9
9
  /**
@@ -77,6 +77,6 @@ export declare interface PlaintextScanOptions {
77
77
  placeholders?: Record<string, string>
78
78
  strict?: boolean
79
79
  }
80
- export type PlaintextReason = 'plaintext-secret' | 'plaintext-value';
80
+ export type PlaintextReason = 'plaintext-secret' | 'plaintext-value' | 'invalid-public-key' | 'private-key-material';
81
81
  // Re-exported so `@stacksjs/env` keeps the same public surface.
82
82
  export { isEncryptedValue };
package/dist/plaintext.js CHANGED
@@ -1,2 +1,2 @@
1
- import{isEncryptedValue}from"./encrypted-value";export{isEncryptedValue};export const SECRET_NAME_PATTERN=/(?:^|_)(?:PASSWORD|PASSWD|SECRET|TOKEN|APIKEY|API_KEY|CREDENTIAL|CREDENTIALS|PRIVATE|SALT|DSN|CERT|CIPHER|SIGNATURE|WEBHOOK)(?:_|$)|(?:^|_)(?:ACCESS|SECRET)_KEY(?:_|$)|_KEY$/;const CRYPTO_METADATA_PATTERN=/^DOTENV_(?:PUBLIC|PRIVATE)_KEY/,EXPANSION_PATTERN=/^\$\{[^}]+\}$/;export function plaintextSecrets(values,options={}){const{placeholders={},strict=!1}=options,findings=[];for(const[key,value]of Object.entries(values)){if(!value||value.trim()==="")continue;if(isEncryptedValue(value))continue;if(CRYPTO_METADATA_PATTERN.test(key))continue;if(EXPANSION_PATTERN.test(value.trim()))continue;const looksSecret=SECRET_NAME_PATTERN.test(key),isPlaceholder=Object.prototype.hasOwnProperty.call(placeholders,key)&&placeholders[key]===value;if(looksSecret&&!isPlaceholder){findings.push({key,reason:"plaintext-secret"});continue}if(strict)findings.push({key,reason:"plaintext-value"})}return findings}export function trackedEnvFiles(gitLsFilesOutput){return gitLsFilesOutput.split(`
1
+ import{isEncryptedValue}from"./encrypted-value";import{isValidPublicKey}from"./crypto";export{isEncryptedValue};export const SECRET_NAME_PATTERN=/(?:^|_)(?:PASSWORD|PASSWD|SECRET|TOKEN|APIKEY|API_KEY|CREDENTIAL|CREDENTIALS|PRIVATE|SALT|DSN|CERT|CIPHER|SIGNATURE|WEBHOOK)(?:_|$)|(?:^|_)(?:ACCESS|SECRET)_KEY(?:_|$)|_KEY$/;const CRYPTO_METADATA_PATTERN=/^DOTENV_(?:PUBLIC|PRIVATE)_KEY/,EXPANSION_PATTERN=/^\$\{[^}]+\}$/;export function plaintextSecrets(values,options={}){const{placeholders={},strict=!1}=options,findings=[];for(const[key,value]of Object.entries(values)){if(strict&&key.startsWith("DOTENV_PUBLIC_KEY")){if(!isValidPublicKey(value))findings.push({key,reason:"invalid-public-key"});continue}if(strict&&key.startsWith("DOTENV_PRIVATE_KEY")){if(value.trim())findings.push({key,reason:"private-key-material"});continue}if(!value||value.trim()==="")continue;if(isEncryptedValue(value))continue;if(CRYPTO_METADATA_PATTERN.test(key))continue;if(EXPANSION_PATTERN.test(value.trim()))continue;const looksSecret=SECRET_NAME_PATTERN.test(key),isPlaceholder=Object.prototype.hasOwnProperty.call(placeholders,key)&&placeholders[key]===value;if(looksSecret&&!isPlaceholder){findings.push({key,reason:"plaintext-secret"});continue}if(strict)findings.push({key,reason:"plaintext-value"})}return findings}export function trackedEnvFiles(gitLsFilesOutput){return gitLsFilesOutput.split(`
2
2
  `).map((line)=>line.trim()).filter(Boolean).filter((file)=>file!==".env.example").filter((file)=>/(?:^|\/)\.env(?:\.|$)/.test(file)).filter((file)=>!file.endsWith(".env.keys"))}
package/dist/plugin.js CHANGED
@@ -1 +1 @@
1
- import{existsSync}from"node:fs";import{readFileSync}from"node:fs";import{resolve}from"node:path";import{decryptValue,getPrivateKey}from"./crypto";import{parse}from"./parser";import{isEncryptedValue}from"./encrypted-value";import{plaintextEnv}from"./plaintext-env";function normalizeEnvName(value){if(!value||isEncryptedValue(value))return;const normalized=value.trim().toLowerCase();if(!/^[a-z0-9_-]+$/.test(normalized))return;if(normalized==="prod")return"production";if(normalized==="stage")return"staging";if(normalized==="dev")return"development";return normalized}let cachedKeyEnv=null,cachedKeyCwd=null,cachedPrivateKey,loadedEnvName;function keyFromKeysFile(envName,cwd,keysFile=".env.keys"){const keysPath=resolve(cwd,keysFile);if(!existsSync(keysPath))return;try{const{parsed}=parse(readFileSync(keysPath,"utf-8"));if(envName){const specific=parsed[`DOTENV_PRIVATE_KEY_${envName.toUpperCase()}`];if(specific)return specific}return parsed.DOTENV_PRIVATE_KEY}catch{return}}export function activeEnvName(options={}){return normalizeEnvName(options.env)||loadedEnvName||normalizeEnvName("development")||normalizeEnvName(process.env.DOTENV_ENV)||normalizeEnvName(process.env.APP_ENV)||"development"}export function resolvePrivateKey(options={}){const envName=activeEnvName(options),cwd=resolve(options.cwd||process.cwd());if(cachedKeyEnv===envName&&cachedKeyCwd===cwd)return cachedPrivateKey;let key=getPrivateKey(envName);if(!key)key=process.env.DOTENV_PRIVATE_KEY;if(!key)key=keyFromKeysFile(envName,cwd);cachedKeyEnv=envName;cachedKeyCwd=cwd;cachedPrivateKey=key;return key}export function resetPrivateKeyCache(){cachedKeyEnv=null;cachedKeyCwd=null;cachedPrivateKey=void 0;loadedEnvName=void 0}export function decryptEnvValue(value,options={}){if(!isEncryptedValue(value))return value;const key=resolvePrivateKey(options);if(!key)return;try{const normalized=value.startsWith("enc:")?`encrypted:${value.slice(4)}`:value;return decryptValue(normalized,key)}catch{return}}export function loadEnv(options={}){const{path:envPaths=[".env"],overload=!1,env:envName,privateKey:customPrivateKey,keysFile=".env.keys",quiet=!1,cwd=process.cwd()}=options,paths=Array.isArray(envPaths)?envPaths:[envPaths],errors=[];let loaded=0;const externalOverrides=new Set(Object.entries(plaintextEnv(process.env)).filter(([,value])=>value!==void 0).map(([key])=>key));let privateKey=customPrivateKey;if(!privateKey&&keysFile){const keysPath=resolve(cwd,keysFile);if(existsSync(keysPath))try{const keysContent=readFileSync(keysPath,"utf-8"),{parsed}=parse(keysContent);if(envName){const keyName=`DOTENV_PRIVATE_KEY_${envName.toUpperCase()}`;privateKey=parsed[keyName]||parsed.DOTENV_PRIVATE_KEY}else privateKey=parsed.DOTENV_PRIVATE_KEY}catch(error){errors.push(`Failed to load keys file: ${error instanceof Error?error.message:"Unknown error"}`)}}if(!privateKey)privateKey=getPrivateKey(envName||"")||getPrivateKey();for(const envPath of paths){const fullPath=resolve(cwd,envPath);if(!existsSync(fullPath)){if(!quiet)errors.push(`File not found: ${fullPath}`);continue}try{const content=readFileSync(fullPath,"utf-8"),{parsed,errors:parseErrors,skippedEncrypted}=parse(content,{privateKey,processEnv:process.env});errors.push(...parseErrors);if(skippedEncrypted.length>0){const usable=plaintextEnv(process.env,skippedEncrypted);for(const key of Object.keys(process.env))if(!Object.hasOwn(usable,key))delete process.env[key];const keyName=envName?`DOTENV_PRIVATE_KEY_${envName.toUpperCase()}`:"DOTENV_PRIVATE_KEY",reason=privateKey?"decryption failed":`${keyName} not set`,preview=skippedEncrypted.slice(0,5).join(", "),rest=skippedEncrypted.length>5?`, \u2026 +${skippedEncrypted.length-5} more`:"";console.warn(`[env] warning: skipped ${skippedEncrypted.length} encrypted value(s) in ${envPath} (${reason}; existing plaintext or defaults apply): ${preview}${rest}`)}let applied=0;for(const[key,value]of Object.entries(parsed)){if(key==="DOTENV_PUBLIC_KEY")continue;const existing=process.env[key],isStaleCiphertext=isEncryptedValue(existing)&&value!==existing;if(overload||!externalOverrides.has(key)||isStaleCiphertext){process.env[key]=value;loaded++;applied++}}if(!quiet&&!process.env.__ENV_LOADED__){console.error(`[env] loaded ${applied}/${Object.keys(parsed).length} variables from ${envPath}`);process.env.__ENV_LOADED__="1"}}catch(error){errors.push(`Failed to load ${fullPath}: ${error instanceof Error?error.message:"Unknown error"}`)}}scrubForeignCiphertext(privateKey);return{loaded,errors}}const reportedForeignCiphertext=new Set;function scrubForeignCiphertext(privateKey){const undecryptable=[];for(const[key,value]of Object.entries(process.env)){if(!isEncryptedValue(value))continue;let plaintext;if(privateKey)try{plaintext=decryptValue(value.startsWith("enc:")?`encrypted:${value.slice(4)}`:value,privateKey)}catch{}if(plaintext===void 0)undecryptable.push(key);else process.env[key]=plaintext}if(!undecryptable.length)return;const usable=plaintextEnv(process.env,undecryptable);for(const key of Object.keys(process.env))if(!Object.hasOwn(usable,key))delete process.env[key];const fresh=undecryptable.filter((key)=>!reportedForeignCiphertext.has(key));if(!fresh.length)return;for(const key of fresh)reportedForeignCiphertext.add(key);const preview=fresh.slice(0,5).join(", "),rest=fresh.length>5?`, \u2026 +${fresh.length-5} more`:"";console.warn(`[env] warning: unset ${fresh.length} encrypted value(s) no loaded env file could decrypt (Bun preloads .env.<NODE_ENV> on its own): ${preview}${rest}`)}export function envPlugin(options={}){return{name:"env-plugin",setup(build){loadEnv(options)}}}export function autoLoadEnv(options={}){const env=normalizeEnvName(options.env)||normalizeEnvName("development")||normalizeEnvName(process.env.DOTENV_ENV)||normalizeEnvName(process.env.APP_ENV)||"development",cwd=options.cwd||process.cwd();if(loadedEnvName!==env){loadedEnvName=env;cachedKeyEnv=null;cachedKeyCwd=null;cachedPrivateKey=void 0}const paths=[],addIfPresent=(file)=>{if(!paths.includes(file)&&existsSync(resolve(cwd,file)))paths.push(file)};addIfPresent(".env");addIfPresent(".env.local");const envFile=`.env.${env}`,envLocalFile=`.env.${env}.local`;addIfPresent(envFile);addIfPresent(envLocalFile);return loadEnv({...options,path:paths,env})}export default envPlugin;
1
+ import{existsSync}from"node:fs";import{readFileSync}from"node:fs";import{resolve}from"node:path";import{decryptValue,getPrivateKey}from"./crypto";import{parse}from"./parser";import{isEncryptedValue}from"./encrypted-value";import{plaintextEnv}from"./plaintext-env";function normalizeEnvName(value){if(!value||isEncryptedValue(value))return;const normalized=value.trim().toLowerCase();if(!/^[a-z0-9_-]+$/.test(normalized))return;if(normalized==="prod")return"production";if(normalized==="stage")return"staging";if(normalized==="dev")return"development";return normalized}let cachedKeyEnv=null,cachedKeyCwd=null,cachedPrivateKey,loadedEnvName;function keyFromKeysFile(envName,cwd,keysFile=".env.keys"){const keysPath=resolve(cwd,keysFile);if(!existsSync(keysPath))return;try{const{parsed}=parse(readFileSync(keysPath,"utf-8"));if(envName){const specific=parsed[`DOTENV_PRIVATE_KEY_${envName.toUpperCase()}`];if(specific)return specific}return parsed.DOTENV_PRIVATE_KEY}catch{return}}export function activeEnvName(options={}){return normalizeEnvName(options.env)||loadedEnvName||normalizeEnvName(process.env.NODE_ENV)||normalizeEnvName(process.env.DOTENV_ENV)||normalizeEnvName(process.env.APP_ENV)||"development"}export function resolvePrivateKey(options={}){const envName=activeEnvName(options),cwd=resolve(options.cwd||process.cwd());if(cachedKeyEnv===envName&&cachedKeyCwd===cwd)return cachedPrivateKey;let key=getPrivateKey(envName);if(!key)key=process.env.DOTENV_PRIVATE_KEY;if(!key)key=keyFromKeysFile(envName,cwd);cachedKeyEnv=envName;cachedKeyCwd=cwd;cachedPrivateKey=key;return key}export function resetPrivateKeyCache(){cachedKeyEnv=null;cachedKeyCwd=null;cachedPrivateKey=void 0;loadedEnvName=void 0}export function decryptEnvValue(value,options={}){if(!isEncryptedValue(value))return value;const key=resolvePrivateKey(options);if(!key)return;try{const normalized=value.startsWith("enc:")?`encrypted:${value.slice(4)}`:value;return decryptValue(normalized,key)}catch{return}}export function loadEnv(options={}){const{path:envPaths=[".env"],overload=!1,env:envName,privateKey:customPrivateKey,keysFile=".env.keys",quiet=!1,cwd=process.cwd()}=options,paths=Array.isArray(envPaths)?envPaths:[envPaths],errors=[];let loaded=0;const externalOverrides=new Set(Object.entries(plaintextEnv(process.env)).filter(([,value])=>value!==void 0).map(([key])=>key));let privateKey=customPrivateKey;if(!privateKey&&keysFile){const keysPath=resolve(cwd,keysFile);if(existsSync(keysPath))try{const keysContent=readFileSync(keysPath,"utf-8"),{parsed}=parse(keysContent);if(envName){const keyName=`DOTENV_PRIVATE_KEY_${envName.toUpperCase()}`;privateKey=parsed[keyName]||parsed.DOTENV_PRIVATE_KEY}else privateKey=parsed.DOTENV_PRIVATE_KEY}catch(error){errors.push(`Failed to load keys file: ${error instanceof Error?error.message:"Unknown error"}`)}}if(!privateKey)privateKey=getPrivateKey(envName||"")||getPrivateKey();for(const envPath of paths){const fullPath=resolve(cwd,envPath);if(!existsSync(fullPath)){if(!quiet)errors.push(`File not found: ${fullPath}`);continue}try{const content=readFileSync(fullPath,"utf-8"),{parsed,errors:parseErrors,skippedEncrypted}=parse(content,{privateKey,processEnv:process.env});errors.push(...parseErrors);if(skippedEncrypted.length>0){const usable=plaintextEnv(process.env,skippedEncrypted);for(const key of Object.keys(process.env))if(!Object.hasOwn(usable,key))delete process.env[key];const keyName=envName?`DOTENV_PRIVATE_KEY_${envName.toUpperCase()}`:"DOTENV_PRIVATE_KEY",reason=privateKey?"decryption failed":`${keyName} not set`,preview=skippedEncrypted.slice(0,5).join(", "),rest=skippedEncrypted.length>5?`, \u2026 +${skippedEncrypted.length-5} more`:"";console.warn(`[env] warning: skipped ${skippedEncrypted.length} encrypted value(s) in ${envPath} (${reason}; existing plaintext or defaults apply): ${preview}${rest}`)}let applied=0;for(const[key,value]of Object.entries(parsed)){if(key==="DOTENV_PUBLIC_KEY")continue;const existing=process.env[key],isStaleCiphertext=isEncryptedValue(existing)&&value!==existing;if(overload||!externalOverrides.has(key)||isStaleCiphertext){process.env[key]=value;loaded++;applied++}}if(!quiet&&!process.env.__ENV_LOADED__){console.error(`[env] loaded ${applied}/${Object.keys(parsed).length} variables from ${envPath}`);process.env.__ENV_LOADED__="1"}}catch(error){errors.push(`Failed to load ${fullPath}: ${error instanceof Error?error.message:"Unknown error"}`)}}scrubForeignCiphertext(privateKey);return{loaded,errors}}const reportedForeignCiphertext=new Set;function scrubForeignCiphertext(privateKey){const undecryptable=[];for(const[key,value]of Object.entries(process.env)){if(!isEncryptedValue(value))continue;let plaintext;if(privateKey)try{plaintext=decryptValue(value.startsWith("enc:")?`encrypted:${value.slice(4)}`:value,privateKey)}catch{}if(plaintext===void 0)undecryptable.push(key);else process.env[key]=plaintext}if(!undecryptable.length)return;const usable=plaintextEnv(process.env,undecryptable);for(const key of Object.keys(process.env))if(!Object.hasOwn(usable,key))delete process.env[key];const fresh=undecryptable.filter((key)=>!reportedForeignCiphertext.has(key));if(!fresh.length)return;for(const key of fresh)reportedForeignCiphertext.add(key);const preview=fresh.slice(0,5).join(", "),rest=fresh.length>5?`, \u2026 +${fresh.length-5} more`:"";console.warn(`[env] warning: unset ${fresh.length} encrypted value(s) no loaded env file could decrypt (Bun preloads .env.<NODE_ENV> on its own): ${preview}${rest}`)}export function envPlugin(options={}){return{name:"env-plugin",setup(build){loadEnv(options)}}}export function autoLoadEnv(options={}){const env=normalizeEnvName(options.env)||normalizeEnvName(process.env.NODE_ENV)||normalizeEnvName(process.env.DOTENV_ENV)||normalizeEnvName(process.env.APP_ENV)||"development",cwd=options.cwd||process.cwd();if(loadedEnvName!==env){loadedEnvName=env;cachedKeyEnv=null;cachedKeyCwd=null;cachedPrivateKey=void 0}const paths=[],addIfPresent=(file)=>{if(!paths.includes(file)&&existsSync(resolve(cwd,file)))paths.push(file)};addIfPresent(".env");addIfPresent(".env.local");const envFile=`.env.${env}`,envLocalFile=`.env.${env}.local`;addIfPresent(envFile);addIfPresent(envLocalFile);return loadEnv({...options,path:paths,env})}export default envPlugin;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/env",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.75.256",
5
+ "version": "0.75.257",
6
6
  "description": "Stacks env helper methods.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -56,10 +56,10 @@
56
56
  "prepublishOnly": "bun run build"
57
57
  },
58
58
  "dependencies": {
59
- "@stacksjs/path": "0.75.256"
59
+ "@stacksjs/path": "0.75.257"
60
60
  },
61
61
  "devDependencies": {
62
62
  "better-dx": "^0.2.24",
63
- "@stacksjs/validation": "0.75.256"
63
+ "@stacksjs/validation": "0.75.257"
64
64
  }
65
65
  }