soon-env
v0.2.0
Published
Encrypt and decrypt .soonenv files: ciphertext on disk, plaintext when you need it. CLI + Node API, AES-256-GCM + scrypt.
Maintainers
Readme
soon-env
.soonenv加密环境变量文件的读写工具:磁盘上永远是密文,需要时才变成明文。 命令行 + Node API 双入口,零运行时依赖。
这是 .soonenv 格式的唯一实现:VS Code 扩展 soon-env-edit
在运行时用的也是它(通过 workspace 依赖打进 vsix),所以格式永远不会两边漂移。
- 加密:
scrypt(N=32768, r=8, p=1)派生 32 字节密钥 →AES-256-GCM - 一个文件 = 一行密文,salt / IV / 认证标签都在文件里,自包含
- 密码来源:函数参数 >
SOON_ENV_PW环境变量 - 零依赖,同时提供 CJS / ESM / TypeScript 类型
安装
pnpm add soon-env # 或 npm install soon-env要求 Node.js >= 18.18。
命令行
# 密码只从环境变量读,不提供命令行参数(避免泄漏到 shell history / 进程列表)
export SOON_ENV_PW='your-password'
soon-env encrypt .env # 生成同目录的 .env.soonenv
soon-env decrypt .env.soonenv # 明文打到 stdout
soon-env decrypt .env.soonenv -o .env.tmp --force
pnpx soon-env decrypt .env.soonenv # 不想全局安装也可以直接用| 命令 | 默认行为 |
| --- | --- |
| soon-env encrypt <file> | 生成 <file>.soonenv(同目录),进度信息写到 stderr |
| soon-env decrypt <file> | 明文写到 stdout(方便 \| grep / \| xargs) |
| 选项 | 说明 |
| --- | --- |
| -o, --output <file> | 指定输出文件;已存在时默认拒绝 |
| --stdout | encrypt 时把密文打到 stdout,不落盘 |
| -f, --force | 覆盖已存在的输出文件 |
| --password-env <NAME> | 换个环境变量名读密码(默认 SOON_ENV_PW) |
| -q, --quiet | 不输出进度行 |
| -h, --help / -v, --version | 帮助 / 版本 |
| 退出码 | 含义 |
| --- | --- |
| 0 | 成功 |
| 1 | 运行时错误(密码错误、格式损坏、文件不存在……) |
| 2 | 用法错误(命令/参数写错) |
常见用法:
# 加密项目里所有的 .env* 文件
for f in .env .env.local .env.production; do soon-env encrypt "$f"; done
# 临时用一次密码,不导出到整个 shell
SOON_ENV_PW="$(pass show soon)" soon-env decrypt .env.soonenv | grep DB_HOST
# 在 CI 里生成密文(密码来自 secret)
SOON_ENV_PW="$SOON_ENV_SECRET" soon-env encrypt .env --forceNode API
import {
encryptEnvContent,
decryptEnvContent,
encryptEnvFile,
decryptEnvFile,
} from 'soon-env';
// 内容:传入 env 文本,返回密文文本
const cipher = encryptEnvContent('A=1\n', 'pw'); // 显式传密码
const cipher2 = encryptEnvContent('A=1\n'); // 从 SOON_ENV_PW 取
const plain = decryptEnvContent(cipher, 'pw');
// 文件:默认在源文件旁边生成 <文件名>.soonenv
const outputPath = await encryptEnvFile('.env'); // -> .env.soonenv
await encryptEnvFile('.env', { output: 'dist/.env.soonenv' });
await encryptEnvFile('.env', { force: true }); // 覆盖已存在
const plain2 = await decryptEnvFile('.env.soonenv');密码解析优先级对所有 API 一致:
- 显式传入的
password(按原样使用,不做任何加工) options.envVar指定的环境变量(默认SOON_ENV_PW)- 都没有 → 抛
SoonEnvPasswordMissingError
从环境变量取到的密码会剥掉结尾的换行符 —— SOON_ENV_PW=$(cat pw.txt) 或 CI 里
echo 出来的值不会因为多个 \n 而解不开。
错误类型
| 错误 | 什么时候抛 |
| --- | --- |
| SoonEnvPasswordMissingError | 没有可用的密码 |
| SoonEnvPasswordError | 密码错误(GCM 认证失败) |
| SoonEnvFormatError | 内容不是合法的 SoonEnv 密文 |
| SoonEnvFileError | 文件不存在(code: 'ENOENT')或输出已存在(code: 'EEXIST') |
都继承自 SoonEnvError,可以一次性 catch。
脱敏模板与路径规则
生成「给别人和 AI 看的结构文件」用的规则也在这里,和扩展完全同源:
import { maskEnvContent, valueFingerprint, maskedPathFor, soonEnvPathFor } from 'soon-env';
maskEnvContent('# 数据库\nDB_PASSWORD=real\n');
// '# 数据库\nDB_PASSWORD=soon-env-encrypted\n'
// 传入文件密钥,就在每个值后面附上该值的不可逆指纹
maskEnvContent('# 数据库\nDB_PASSWORD=real\n', { fingerprintKey: material.key });
// '# 数据库\nDB_PASSWORD=soon-env-encrypted:8b45d713\n'
soonEnvPathFor('/p/.env'); // '/p/.env.soonenv'
maskedPathFor('/p/.env.soonenv'); // '/p/.env'key、注释、空行、缩进、换行符(CRLF/LF)、行尾注释都会原样保留,只有值被替换成占位符。
MaskOptions:
| 选项 | 说明 |
| --- | --- |
| maskValue | 占位符文本,默认 soon-env-encrypted |
| fingerprintKey | 用于算指纹的密钥(通常是该 .soonenv 的加密密钥);不传就不加指纹 |
| fingerprintLength | 指纹长度(hex 字符数),默认 8 |
值指纹(valueFingerprint(key, value))解决的是「密文没法 review」:
- 同一个值永远得到同一个指纹 → 内容没变时生成的模板逐字节一致,没改的行不会出现在 diff 里;
- 值一改指纹就变 → 提交前能从生成的
.env看出到底改了哪些变量; - 它是
HMAC-SHA256(从密钥派生的子密钥, 值)的截断 hex(子密钥用 HKDF 做域分隔), 没有密钥就算不出来、也无法拿字典离线比对,所以不会削弱安全性; - 空值不附指纹;改注释不影响指纹(注释本来就在 diff 里看得见)。
底层 API
需要自己控制交互流程的宿主(例如 VS Code 扩展要自己弹密码框、自己做会话级密钥缓存)
可以直接用底层原语:parseEnvelope / createKeyMaterial / keyMaterialFor /
deriveKey / encryptText / decryptText / tryDecrypt / matchesEnvelope /
isEncryptedPayload。普通调用方用上面的内容级 / 文件级 API 就够了。
密文格式(v1)
磁盘上的文件永远只有一行:
SOONENV1.<base64url(JSON)>JSON 信封:
| 字段 | 说明 |
| --- | --- |
| v | 格式版本,当前 1 |
| kdf | "scrypt" |
| n / r / p | scrypt 参数,当前 32768 / 8 / 1 |
| salt | 16 字节随机盐(base64url) |
| iv | 12 字节随机 IV(base64url) |
| tag | 16 字节 GCM 认证标签(base64url) |
| ct | 密文本身(base64url,空文件的密文长度为 0) |
- 密钥:
scrypt(password.normalize('NFC'), salt, 32) - 附加认证数据(AAD):
SOONENV1.v1|scrypt|<n>|<r>|<p>|<salt>, 也就是说改 KDF 参数或 salt 都会导致解密失败; - 解析时会校验 KDF 参数上下限,恶意文件无法用超大
n拖垮内存; - 向后兼容是硬承诺:
src/test/fixtures/里冻结了一个旧实现的编译副本 和一份由旧版 VS Code 扩展真实生成的密文,compat.test.ts会双向验证 「老文件打得开、新文件旧版也能打开」。想改格式,先想清楚历史密文怎么办。
与 soon-env-edit 的关系
| | soon-env(本包) | soon-env-edit(VS Code 扩展) |
| --- | --- | --- |
| 角色 | 格式与算法的唯一实现 | VS Code 集成层(虚拟文件系统、自定义编辑器、密码缓存) |
| 形态 | npm 包:CLI + Node API | .vsix 扩展,打包时把本包内联进单文件 bundle |
| 加解密 | 这里 | 调用这里,不重复实现 |
所以:改算法、改脱敏规则、改路径推导,都只改本包;扩展那边会自动跟着变。
开发
pnpm install
pnpm --filter soon-env run build # 产出 dist/cjs + dist/esm + .d.ts
pnpm --filter soon-env run test # 构建 + 62 个测试
pnpm --filter soon-env run typecheck目录:
src/
crypto.ts 密文格式与算法(改动等于改磁盘格式,慎之又慎)
envContent.ts 内容级 API
envFile.ts 文件级 API
password.ts SOON_ENV_PW 解析规则
mask.ts 脱敏模板规则
paths.ts 路径推导规则
cli.ts 参数解析 + 命令实现(暴露 run(),可被直接调用测试)
bin.ts 可执行入口(只负责调 run 并设置退出码)
index.ts 公开 API 汇总
test/ 测试(含 fixtures/ 里的兼容性见证文件)测试直接调用 run(argv, io) 而不是 fork 子进程,所以既能断言退出码,也能在禁止
子进程的受限环境里跑。
发布
cd packages/soon-env
pnpm run build
pnpm run test # prepublishOnly 也会自动跑一遍
npm publish --dry-run # 先看看到底会发布哪些文件
npm publishfiles 只放行 dist/cjs、dist/esm、README.md、LICENSE,测试、源码、
临时目录都不会进包。改了 package.json 的 version 记得同步 src/version.ts
(version.test.ts 会校验,漏改就红)。
许可
MIT
