Compare commits
3 Commits
bb7f890a68
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a640bc1faf | ||
|
|
04b48e05e4 | ||
|
|
8dcb675ad2 |
@@ -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
|
||||
|
||||
22
README.md
22
README.md
@@ -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
|
||||
|
||||
@@ -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)' : ''}`);
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user