Skip to content

Commit 9b60432

Browse files
committed
feat(cli): make keychain backend strictly opt-in (alpha.8)
Hot-fix on top of alpha.7. The keychain auto-load was implicit: the CLI tried to read from OS keychain on EVERY command, even when the user had never run `sealed-env keychain push`. On Windows that meant ~300 ms of PowerShell spawn overhead per CLI call (×3 for the three SEALED_ENV_* names). Not acceptable for a tool you might run dozens of times in a session. Solution: `sealed-env keychain push` now writes a small marker file `.sealed-env.json` at the project root with: { "storage": "keychain", "backend": "Windows DPAPI (per-user)", "createdAt": "..." } Safe to commit — no secrets, just config. Lets a team standardize on keychain across machines. The auto-loader now checks for that marker (or the `SEALED_ENV_USE_KEYCHAIN=1` env var) BEFORE even loading the keychain module. If neither is present, the keychain code path is fully bypassed. `keychain clear` and `pull` remove the marker. `keychain status` reports whether the marker is present. Measured: `sealed-env doctor` dropped from ~1.7 s to ~250 ms when the project hasn't opted in. Keychain feature remains identically functional for projects that did. Bumps to 0.1.0-alpha.8. No wire-format changes.
1 parent fc8f215 commit 9b60432

8 files changed

Lines changed: 160 additions & 40 deletions

File tree

CHANGELOG.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,40 @@ files written today will remain readable forever. See [SPEC.md](./SPEC.md).
1414

1515
---
1616

17+
## [0.1.0-alpha.8] — 2026-05-07
18+
19+
UX hot-fix on top of alpha.7. **No wire-format changes.**
20+
21+
### Changed
22+
23+
- **Keychain backend is now strictly opt-in.** alpha.7 always tried to
24+
read from the OS keychain on every CLI invocation, even for users
25+
who had never run `sealed-env keychain push` — that meant ~300 ms of
26+
PowerShell/security/secret-tool spawn overhead per command. Not OK.
27+
28+
alpha.8 only checks the keychain when the project has explicitly
29+
opted in:
30+
31+
- `sealed-env keychain push` now writes `.sealed-env.json` (a small
32+
JSON marker file with `{ "storage": "keychain", ... }`) to the
33+
project root. Safe to commit — contains no secrets.
34+
- The auto-loader checks for that marker file BEFORE even loading
35+
the keychain backend module. If the marker is absent, the
36+
keychain code path is fully bypassed.
37+
- `SEALED_ENV_USE_KEYCHAIN=1` is honored as an alternative opt-in
38+
for one-off / CI scenarios.
39+
40+
`sealed-env keychain clear` and `pull` remove the marker.
41+
`sealed-env keychain status` now reports whether the marker is
42+
present so users can audit their setup.
43+
44+
Measured impact: `sealed-env doctor` overhead dropped from ~1.7 s
45+
to ~250 ms when the project hasn't opted in. The keychain feature
46+
remains exactly as functional as in alpha.7 for projects that have
47+
opted in.
48+
49+
---
50+
1751
## [0.1.0-alpha.7] — 2026-05-07
1852

1953
Operator ergonomics + hardened key storage. **No wire-format changes**

docs/07-operational-guide.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ Add the starter to `pom.xml`:
195195
<dependency>
196196
<groupId>io.github.davidalmeidac</groupId>
197197
<artifactId>sealed-env-spring-boot-starter</artifactId>
198-
<version>0.1.0-alpha.7</version>
198+
<version>0.1.0-alpha.8</version>
199199
</dependency>
200200
```
201201

java/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
<groupId>io.github.davidalmeidac</groupId>
99
<artifactId>sealed-env-parent</artifactId>
10-
<version>0.1.0-alpha.7</version>
10+
<version>0.1.0-alpha.8</version>
1111
<packaging>pom</packaging>
1212

1313
<name>sealed-env (parent)</name>

java/sealed-env-core/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
<parent>
99
<groupId>io.github.davidalmeidac</groupId>
1010
<artifactId>sealed-env-parent</artifactId>
11-
<version>0.1.0-alpha.7</version>
11+
<version>0.1.0-alpha.8</version>
1212
</parent>
1313

1414
<artifactId>sealed-env-core</artifactId>

java/sealed-env-spring-boot-starter/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
<parent>
99
<groupId>io.github.davidalmeidac</groupId>
1010
<artifactId>sealed-env-parent</artifactId>
11-
<version>0.1.0-alpha.7</version>
11+
<version>0.1.0-alpha.8</version>
1212
</parent>
1313

1414
<artifactId>sealed-env-spring-boot-starter</artifactId>

node/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sealed-env",
3-
"version": "0.1.0-alpha.7",
3+
"version": "0.1.0-alpha.8",
44
"description": "Encrypted .env files with optional TOTP unsealing for production deploys. Cross-stack with the Java port. One minimal CLI dependency.",
55
"keywords": [
66
"dotenv",

node/src/cli/commands/keychain.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,43 @@ import { createInterface } from 'node:readline/promises';
2828

2929
import { SealedEnvError } from '../../core/errors.js';
3030
import { parseDotenv } from '../utils/io.js';
31+
32+
/**
33+
* Project-level marker that opts this directory into keychain-backed
34+
* auto-loading. Without this file (or `SEALED_ENV_USE_KEYCHAIN=1` in
35+
* the env), the auto-load helper skips the keychain entirely — so
36+
* users who never opt in pay zero overhead.
37+
*
38+
* Safe to commit: contains no secrets, just the choice of backend.
39+
* Different team members can then independently `keychain push` their
40+
* own credentials and the project's behavior stays consistent.
41+
*/
42+
const MARKER_FILE = '.sealed-env.json';
43+
44+
function writeMarker(backendLabel: string): void {
45+
const path = resolve(MARKER_FILE);
46+
const cfg = {
47+
$schema: 'https://github.com/davidalmeidac/sealed-env/blob/main/SPEC.md',
48+
storage: 'keychain' as const,
49+
backend: backendLabel,
50+
createdAt: new Date().toISOString(),
51+
note:
52+
'Created by `sealed-env keychain push`. Tells the auto-loader to ' +
53+
'check the OS keychain. Safe to commit. Remove with `sealed-env keychain clear`.',
54+
};
55+
writeFileSync(path, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o644 });
56+
}
57+
58+
function removeMarker(): void {
59+
const path = resolve(MARKER_FILE);
60+
if (existsSync(path)) {
61+
try {
62+
unlinkSync(path);
63+
} catch {
64+
/* ignore */
65+
}
66+
}
67+
}
3168
import {
3269
KEYCHAIN_NAMES,
3370
detectBackend,
@@ -109,6 +146,12 @@ async function pushCommand(
109146
process.stdout.write(` [✓] ${name}\n`);
110147
}
111148

149+
// Drop the opt-in marker so future commands check the keychain.
150+
// Without this, the auto-loader doesn't even spawn the keychain
151+
// backend (saves ~300ms / command for users who never opted in).
152+
writeMarker(backend.label);
153+
process.stdout.write(`✓ Wrote ${MARKER_FILE} (commit it to standardize the team)\n`);
154+
112155
// Offer to delete .env.local. The whole point of pushing is to stop
113156
// having a plaintext copy on disk. We confirm explicitly because
114157
// accidentally deleting your only copy of the keys could lock the
@@ -182,15 +225,31 @@ async function pullCommand(
182225
);
183226
}
184227
writeFileSync(path, lines.join('\n') + '\n', { mode: 0o600 });
228+
// Pull means "I want the .env.local back" — drop the keychain marker
229+
// so auto-load uses the file copy from now on. Keychain entries
230+
// remain stored unless user runs `clear` separately.
231+
removeMarker();
185232
process.stdout.write(
186233
`✓ Pulled ${count} entr${count === 1 ? 'y' : 'ies'} from ${backend.label} → .env.local (mode 0600)\n`,
187234
);
235+
process.stdout.write(
236+
`✓ Removed ${MARKER_FILE} — auto-load will read from .env.local now\n`,
237+
);
238+
process.stdout.write(
239+
`(keychain entries kept; run "sealed-env keychain clear" if you want them gone too)\n`,
240+
);
188241
}
189242

190243
function statusCommand(
191244
backend: ReturnType<typeof detectBackend> & {},
192245
): void {
193-
process.stdout.write(`Backend: ${backend.label}\n\n`);
246+
process.stdout.write(`Backend: ${backend.label}\n`);
247+
const markerPath = resolve(MARKER_FILE);
248+
const markerExists = existsSync(markerPath);
249+
const envOptIn = process.env['SEALED_ENV_USE_KEYCHAIN'] === '1';
250+
process.stdout.write(`Opt-in: ${MARKER_FILE} = ${markerExists ? 'present ✓' : 'missing'}`);
251+
if (envOptIn) process.stdout.write(' (SEALED_ENV_USE_KEYCHAIN=1)');
252+
process.stdout.write('\n\n');
194253
for (const name of KEYCHAIN_NAMES) {
195254
const v = backend.read(name);
196255
if (v === null) {
@@ -226,7 +285,9 @@ async function clearCommand(
226285
for (const name of KEYCHAIN_NAMES) {
227286
backend.remove(name);
228287
}
288+
removeMarker();
229289
process.stdout.write(`✓ Cleared sealed-env entries from ${backend.label}\n`);
290+
process.stdout.write(`✓ Removed ${MARKER_FILE} — auto-load will fall back to .env.local\n`);
230291
}
231292

232293
function installHintFor(label: string): string {

node/src/cli/utils/io.ts

Lines changed: 59 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -126,16 +126,18 @@ export function resealLikeSource(
126126
*
127127
* 1. `process.env` — values already set by the parent shell or CI
128128
* always win. We never override.
129-
* 2. **OS keychain** (Windows DPAPI / macOS Keychain / libsecret)
130-
* — encrypted at rest, locked to the user login. If the operator
131-
* has run `sealed-env keychain push` previously, the values live
132-
* here and we read them on demand.
133-
* 3. `.env.local` in `cwd` — fallback for projects that haven't
134-
* adopted the keychain yet, or for CI bootstrap.
129+
* 2. **OS keychain** — only if the project opted in by running
130+
* `sealed-env keychain push` (which creates `.sealed-env.json` in
131+
* cwd as a marker), or if `SEALED_ENV_USE_KEYCHAIN=1` is set
132+
* explicitly. Without one of those signals we DON'T spawn the
133+
* platform CLI on every command — that would add ~300ms of
134+
* overhead for users who never enabled the keychain backend.
135+
* 3. `.env.local` in `cwd` — the default for projects that haven't
136+
* adopted the keychain.
135137
*
136-
* Returns the count of keys actually loaded, plus the source string
137-
* (`"keychain"` / `".env.local"` / `""` if nothing was loaded), so the
138-
* caller can log something useful to stderr without printing values.
138+
* Returns the count of keys actually loaded plus the source string
139+
* (`"OS keychain"` / `".env.local"` / `""` if nothing was loaded), so
140+
* the caller can log a stderr breadcrumb without printing values.
139141
*
140142
* Only `SEALED_ENV_*` keys are touched. Other variables in `.env.local`
141143
* (if any) are ignored — that prevents this helper from acting as a
@@ -144,36 +146,35 @@ export function resealLikeSource(
144146
export function autoloadSealedEnvLocal(
145147
cwd: string = process.cwd(),
146148
): { loaded: number; source: string } {
147-
// Step 1: keychain first (more secure). We try lazily — only import
148-
// the backend when we actually need it, so non-enterprise / non-
149-
// keychain users don't pay the spawn cost.
150149
let loaded = 0;
151150
let sourceUsed = '';
152-
try {
153-
// Lazy require to avoid loading the keychain module on every
154-
// command invocation when no keychain is set up.
155-
const requireFn = createRequire(import.meta.url);
156-
const keychainMod = requireFn('./keychain.js') as {
157-
detectBackend: () => {
158-
isAvailable(): boolean;
159-
read(name: string): string | null;
160-
} | null;
161-
KEYCHAIN_NAMES: readonly string[];
162-
};
163-
const backend = keychainMod.detectBackend();
164-
if (backend && backend.isAvailable()) {
165-
for (const name of keychainMod.KEYCHAIN_NAMES) {
166-
if (process.env[name] !== undefined) continue; // host wins
167-
const v = backend.read(name);
168-
if (v !== null) {
169-
process.env[name] = v;
170-
loaded++;
171-
sourceUsed = 'OS keychain';
151+
152+
// Step 1: keychain — but only if the project has opted in.
153+
if (isKeychainEnabled(cwd)) {
154+
try {
155+
const requireFn = createRequire(import.meta.url);
156+
const keychainMod = requireFn('./keychain.js') as {
157+
detectBackend: () => {
158+
isAvailable(): boolean;
159+
read(name: string): string | null;
160+
} | null;
161+
KEYCHAIN_NAMES: readonly string[];
162+
};
163+
const backend = keychainMod.detectBackend();
164+
if (backend && backend.isAvailable()) {
165+
for (const name of keychainMod.KEYCHAIN_NAMES) {
166+
if (process.env[name] !== undefined) continue; // host wins
167+
const v = backend.read(name);
168+
if (v !== null) {
169+
process.env[name] = v;
170+
loaded++;
171+
sourceUsed = 'OS keychain';
172+
}
172173
}
173174
}
175+
} catch {
176+
// Backend errored — silently fall through to file-based loading.
174177
}
175-
} catch {
176-
// Backend errored — silently fall through to file-based loading.
177178
}
178179

179180
// Step 2: .env.local fills in anything still missing.
@@ -202,6 +203,30 @@ export function autoloadSealedEnvLocal(
202203
return { loaded, source: sourceUsed };
203204
}
204205

206+
/**
207+
* Check whether this project has opted into keychain-backed auto-load.
208+
* Two signals trigger it:
209+
*
210+
* 1. `.sealed-env.json` in cwd with `{ "storage": "keychain" }` —
211+
* written by `sealed-env keychain push`. Persistent and committable
212+
* so a team can standardize on keychain across machines.
213+
* 2. `SEALED_ENV_USE_KEYCHAIN=1` env var — for one-off / CI override.
214+
*
215+
* Without either, we skip the keychain path entirely. This means users
216+
* who never run `keychain push` pay ZERO overhead from this feature.
217+
*/
218+
export function isKeychainEnabled(cwd: string = process.cwd()): boolean {
219+
if (process.env['SEALED_ENV_USE_KEYCHAIN'] === '1') return true;
220+
const marker = resolve(cwd, '.sealed-env.json');
221+
if (!existsSync(marker)) return false;
222+
try {
223+
const cfg = JSON.parse(readFileSync(marker, 'utf8')) as { storage?: string };
224+
return cfg.storage === 'keychain';
225+
} catch {
226+
return false;
227+
}
228+
}
229+
205230
/**
206231
* Parse plaintext .env style content into key/value pairs. Preserves
207232
* insertion order. Lines that don't match KEY=VALUE are kept as-is in a

0 commit comments

Comments
 (0)