Config + secrets as a single source of truth — made post-quantum-safe.
Declare each variable once, encrypt the secret values with post-quantum crypto so the .env
is safe to commit to git and share over public channels, and project that one declaration into every
surface: local dev, Cloudflare Workers, preview, a teammate's machine, the VS Code extension, the
admin panel.
It's dotenvx's commit-safely model (a public key encrypts, a
private key decrypts, the encrypted .env is committable), with two differences:
@noble/post-quantum
it's pure JS — it runs the same in Node, Bun, the browser, and a Cloudflare Worker.defineEnv makes config a declared thing — typed access, a per-surface manifest,
and a config-health view — the same "declare once, project everywhere" idea as the entity registry,
applied to configuration.Each value is encrypted to a public key (embedded in the .env as SULUK_PUBLIC_KEY). Only the
private key (SULUK_PRIVATE_KEY, kept in a gitignored .env.keys or a secret binding) can decrypt.
So:
.env can live in git and be shared in a Slack message — the ciphertext reveals nothing;wrangler secret put).readPrivateKey (Node/Bun) resolves the key in precedence order:
SULUK_PRIVATE_KEY env var — wins; for CI/prod and secret bindings.~/.suluk/settings.json — a centralized, machine-local store mapping each project to its key, so a
new repo needs no per-repo .env.keys — just an entry here. The project is matched by the running
directory (SULUK_PROJECT_DIR or cwd), by path prefix, falling back to the project name as a path
segment (so worktrees resolve). Override the location with SULUK_SETTINGS_PATH. Shape:{ "projects": [ { "name": "my-app", "path": "/abs/path/my-app",
"env": [ { "key": "SULUK_PRIVATE_KEY", "value": "mlkem768:…" } ] } ] }
.env.keys — the legacy per-repo gitignored file (still honored for back-compat).Both ~/.suluk/settings.json and .env.keys hold the plaintext private key — guard them like any
secret (they live outside the repo / are gitignored, so neither is ever committed). A malformed or
unreadable settings.json is ignored, not fatal: resolution simply falls through to the next source.
suluk-env keygen # ML-KEM-768 keypair → public in .env, private in .env.keys (auto-gitignored)
suluk-env set STRIPE_KEY=sk_live_… # add/update a variable, encrypted (use --plain for non-secrets)
suluk-env encrypt --skip BASE_URL # encrypt every plaintext value in .env in place
suluk-env ls # list variables (masked) + their encrypted/plaintext state
suluk-env get STRIPE_KEY # decrypt + print one value
suluk-env decrypt # print the whole .env decrypted (--out file to write)
suluk-env run -- bun start # decrypt into the environment, then run a command
A committed .env ends up looking like:
SULUK_PUBLIC_KEY="mlkem768:EfIdiAi0Fy…" # commit this — it can only encrypt
BASE_URL="http://localhost:3000" # left plaintext (not a secret)
STRIPE_SECRET_KEY="encrypted:mlkem768:Alu+Ks…" # ciphertext — safe in git
The core (@suluk/env) is runtime-agnostic — Web Crypto + @noble/post-quantum, no node:fs — so it
imports cleanly inside a Worker. The fs helpers + CLI live in @suluk/env/node; the raw primitives
(keygen / encrypt / decrypt / publicFromPrivate) are also available standalone at @suluk/env/crypto
(and re-exported from the main entry).
import { keygen, encrypt, decrypt, loadEnv } from "@suluk/env";
// decrypt a committed .env at runtime — works in Node, Bun, AND a Cloudflare Worker
await loadEnv({ content: envFileText, privateKey: env.SULUK_PRIVATE_KEY }); // → injected into process.env
In a Worker, ship the encrypted .env text in the bundle and pass the private key from a secret binding —
the same one source of truth decrypts on every surface.
defineEnvimport { defineEnv } from "@suluk/env";
export const env = defineEnv({
BASE_URL: { default: "http://localhost:3000", surfaces: ["local", "cloudflare", "preview"] },
STRIPE_SECRET_KEY: { secret: true, required: true, surfaces: ["local", "cloudflare"], description: "Stripe API key" },
RESEND_API_KEY: { secret: true, surfaces: ["cloudflare"] },
});
env.parse(process.env).STRIPE_SECRET_KEY; // typed + validated (required vars are non-null; throws if missing)
env.forSurface("cloudflare"); // which vars to `wrangler secret put` (the deploy planner reads this)
env.manifest(rawEnvRecord()); // config HEALTH: ok | missing | plaintext-secret | empty + encrypted?
manifest() is what the admin panel renders and the VS Code extension surfaces: which keys exist,
which are encrypted at rest, which required ones are missing, and — the high-value check — which secrets
are sitting in plaintext when they should be encrypted.
assertEnv / validateAdd per-var value rules (minLength, pattern, requiredInSurface, forbidInSurface) and validate the
config at boot. assertEnv throws on any error-severity issue so a misconfigured/short/test secret in prod
stops the process instead of shipping; warnings go to onWarn and never throw. validate returns the graded
issue list without throwing.
const env = defineEnv({
STRIPE_SECRET_KEY: {
secret: true, minLength: 20, pattern: /^sk_(test|live)_/,
requiredInSurface: ["cloudflare", "preview"],
forbidInSurface: [{ pattern: /^sk_test_/, surfaces: ["cloudflare"], message: "a TEST Stripe key in prod", severity: "warning" }],
},
});
env.validate(process.env, { surface: "cloudflare" }); // → EnvIssue[] (graded; never throws)
const config = env.assertEnv(process.env, { // throws on any error; warnings → onWarn
surface: "cloudflare",
onWarn: (issue) => console.warn(issue.message),
allow: [], // explicit, auditable per-var error downgrades
});
Per value: encapsulate(publicKey) → (kemCiphertext, sharedSecret); AES-256-GCM(sharedSecret, iv, plaintext).
The token is encrypted:mlkem768: + base64(kemCiphertext[1088] | iv[12] | aesCiphertext+tag). A fresh KEM
encapsulation per value is what lets you add one variable with only the public key. A wrong key or a tampered
token fails the GCM authentication tag → decryption throws.
Candidate tooling for the Suluk (OpenAPI v4) ecosystem. Apache-2.0.