Compare commits

...

3 Commits

Author SHA1 Message Date
Fredrik Johansson
a640bc1faf keep pull: fill saved template with real values by default
All checks were successful
Docker / build-and-push (push) Successful in 1m54s
A bare key/value dump throws away the comments/structure a template
preserves; now a plain pull reconstitutes the real .env from the
template when the vault has one, falling back to the flat dump
otherwise. --template still returns the redacted structure alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 16:52:02 +02:00
Fredrik Johansson
04b48e05e4 Document env templating in the integration guide
All checks were successful
Docker / build-and-push (push) Successful in 1m58s
2026-07-16 10:59:44 +02:00
Fredrik Johansson
8dcb675ad2 Save a redacted .env template alongside pushed secrets
All checks were successful
Docker / build-and-push (push) Successful in 1m51s
keep push now saves a comment/structure-preserving, value-redacted copy
of the source .env next to the encrypted secrets, so context like "why
this exists" or "get this from X" survives ingest instead of being
silently dropped. keep pull --template retrieves it. Old vaults pushed
before this existed keep working (flat payload, no template) and fail
with a clear error rather than crashing if --template is requested.
2026-07-15 20:41:45 +02:00
5 changed files with 97 additions and 8 deletions

View File

@@ -31,6 +31,13 @@ A deploy identity should almost always be granted **read-only** access
needs to `pull`, and a read-only grant means a compromised deploy box
can't also rotate the vault or add itself more recipients.
When you do the first `keep push` for a project, push the *real*,
hand-maintained `.env` rather than a stripped-down one — any `# get this
from X` / `# rotate quarterly` comments in it get preserved (values
redacted) as a template alongside the secrets, retrievable later with
`keep pull <vault> --template`. That's the one moment this context is
available at all; a `.env` reconstructed later from memory won't have it.
## Getting the `keep` CLI onto a machine
Chicken-and-egg: every pattern below assumes `keep` is already a runnable

View File

@@ -116,6 +116,28 @@ keep push myapp/production --file .env.production
keep pull myapp/production > .env
```
## Templates
`keep push` also saves a redacted copy of the source `.env` alongside
the secrets — same keys, comments, and blank lines, values stripped.
Comments explaining *where a value comes from* or *why it exists* are
exactly the kind of context a bare key/value pair throws away, so this
keeps it without keeping the secret itself around unencrypted anywhere.
A plain `keep pull` uses that template by default: if the vault has one,
you get real values filled back into it, comments and all — not just a
bare key/value dump. Ask for the redacted structure itself with:
```bash
keep pull myapp/production --template > .env.example
```
Vaults pushed before this existed just don't have a saved template —
`keep pull` on one of those falls back to a plain key/value dump, and
`keep pull --template` fails with a clear error instead of silently
returning nothing. No migration needed; push again and the template
gets backfilled from whatever `.env` you push next.
## Granting, revoking, rotating
```bash

View File

@@ -1,7 +1,7 @@
import fs from 'node:fs';
import { requireIdentityAndRecipientId } from '../identityStore.js';
import { signedFetch, adminFetch, expectOk } from '../signedFetch.js';
import { parseEnv, serializeEnv } from '../envFormat.js';
import { parseEnv, serializeEnv, buildEnvTemplate, fillEnvTemplate } from '../envFormat.js';
import {
ready,
Identity,
@@ -16,8 +16,13 @@ export async function vaultPush(vaultKey: string, file: string, purge: boolean):
const { identity, recipientId } = await requireIdentityAndRecipientId();
if (!fs.existsSync(file)) throw new Error(`file not found: ${file}`);
const values = parseEnv(fs.readFileSync(file, 'utf8'));
const payload = enc.fromUtf8(JSON.stringify(values));
const raw = fs.readFileSync(file, 'utf8');
const values = parseEnv(raw);
const template = buildEnvTemplate(raw);
// __keepEnvelope marks the new wrapped shape so vaultPull can tell it
// apart from an old vault pushed before templates existed, which is
// just the flat { KEY: value } object with no wrapper at all.
const payload = enc.fromUtf8(JSON.stringify({ __keepEnvelope: true, values, template }));
const existsRes = await expectOk(await signedFetch(identity, recipientId, 'GET', `/api/vaults/${encodeURIComponent(vaultKey)}/exists`));
@@ -55,7 +60,7 @@ export async function vaultPush(vaultKey: string, file: string, purge: boolean):
console.log(`pushed '${vaultKey}' — ${Object.keys(values).length} key${Object.keys(values).length === 1 ? '' : 's'}, wrapped for ${wrapFor.size} recipient${wrapFor.size === 1 ? '' : 's'}${purge ? ' (purged previous version)' : ''}.`);
}
export async function vaultPull(vaultKey: string, format: 'env' | 'json', outFile: string | undefined, previous: boolean): Promise<void> {
export async function vaultPull(vaultKey: string, format: 'env' | 'json', outFile: string | undefined, previous: boolean, wantTemplate: boolean): Promise<void> {
await ready();
const { identity, recipientId } = await requireIdentityAndRecipientId();
@@ -66,9 +71,27 @@ export async function vaultPull(vaultKey: string, format: 'env' | 'json', outFil
if (!symmetricKey) throw new Error('failed to unwrap this vault\'s key — wrong identity, or the grant is stale');
const plaintext = secretboxDecrypt(symmetricKey, enc.fromBase64(data.nonce), enc.fromBase64(data.ciphertext));
const values = JSON.parse(enc.toUtf8(plaintext)) as Record<string, string>;
const parsed = JSON.parse(enc.toUtf8(plaintext)) as Record<string, unknown>;
const out = format === 'json' ? JSON.stringify(values, null, 2) : serializeEnv(values);
// Vaults pushed before templates existed are just the flat { KEY: value }
// object with no wrapper — __keepEnvelope tells the two shapes apart.
const values = (parsed.__keepEnvelope ? parsed.values : parsed) as Record<string, string>;
const template = parsed.__keepEnvelope ? (parsed.template as string | undefined) : undefined;
if (wantTemplate) {
if (template === undefined) throw new Error(`vault '${vaultKey}' has no saved template (pushed before template support, or the source file had no comments to preserve)`);
if (outFile) {
fs.writeFileSync(outFile, template);
console.error(`wrote template to ${outFile}${previous ? ' (previous version)' : ''}`);
} else {
process.stdout.write(template);
}
return;
}
const out = format === 'json'
? JSON.stringify(values, null, 2)
: template !== undefined ? fillEnvTemplate(template, values) : serializeEnv(values);
if (outFile) {
fs.writeFileSync(outFile, out);
console.error(`wrote ${Object.keys(values).length} key(s) to ${outFile}${previous ? ' (previous version)' : ''}`);

View File

@@ -20,3 +20,38 @@ export function serializeEnv(values: Record<string, string>): string {
.map(([k, v]) => `${k}=${/[\s#"']/.test(v) ? JSON.stringify(v) : v}`)
.join('\n') + '\n';
}
// Redacts values while keeping comments, blank lines, and key order intact
// — the whole point is to preserve the "why"/"where to get this" context
// that plain parseEnv() throws away, without keeping the actual secret
// around in the template.
// Inverse of buildEnvTemplate: substitutes real values back into a
// template's `KEY=` lines while leaving comments/blank lines/order intact.
export function fillEnvTemplate(template: string, values: Record<string, string>): string {
return template
.split('\n')
.map(line => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return line;
const eq = line.indexOf('=');
if (eq === -1) return line;
const key = line.slice(0, eq);
const value = values[key.trim()] ?? '';
return `${key}=${/[\s#"']/.test(value) ? JSON.stringify(value) : value}`;
})
.join('\n');
}
export function buildEnvTemplate(text: string): string {
return text
.split('\n')
.map(rawLine => {
const line = rawLine.trimEnd();
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return line;
const eq = line.indexOf('=');
if (eq === -1) return line;
return `${line.slice(0, eq)}=`;
})
.join('\n');
}

View File

@@ -31,7 +31,7 @@ async function main(): Promise<void> {
if (cmd === 'overview') return await adminOverview();
if (cmd === 'push') return await vaultPush(sub, flag(rest, 'file', '.env')!, boolFlag(rest, 'purge'));
if (cmd === 'pull') return await vaultPull(sub, (flag(rest, 'format', 'env') as 'env' | 'json'), flag(rest, 'out'), boolFlag(rest, 'previous'));
if (cmd === 'pull') return await vaultPull(sub, (flag(rest, 'format', 'env') as 'env' | 'json'), flag(rest, 'out'), boolFlag(rest, 'previous'), boolFlag(rest, 'template'));
if (cmd === 'grant') return await vaultGrant(sub, rest[0], boolFlag(rest, 'read-only'));
if (cmd === 'revoke') return await vaultRevoke(sub, rest[0]);
if (cmd === 'log') return await vaultLog(sub);
@@ -54,7 +54,9 @@ function printUsage(): void {
keep identity set-id <recipient-id>
keep push <vault> [--file .env] [--purge] (--purge: don't retain the outgoing version as rollback)
keep pull <vault> [--format env|json] [--out <path>] [--previous]
keep pull <vault> [--format env|json] [--out <path>] [--previous] [--template]
(fills the saved template with real values by default, if one exists;
--template: comments/structure with values redacted, not the secrets)
keep grant <vault> <recipient-id> [--read-only]
keep revoke <vault> <recipient-id> (admin — needs KEEP_ADMIN_PASSWORD)
keep log <vault>