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
|
needs to `pull`, and a read-only grant means a compromised deploy box
|
||||||
can't also rotate the vault or add itself more recipients.
|
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
|
## Getting the `keep` CLI onto a machine
|
||||||
|
|
||||||
Chicken-and-egg: every pattern below assumes `keep` is already a runnable
|
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
|
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
|
## Granting, revoking, rotating
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { requireIdentityAndRecipientId } from '../identityStore.js';
|
import { requireIdentityAndRecipientId } from '../identityStore.js';
|
||||||
import { signedFetch, adminFetch, expectOk } from '../signedFetch.js';
|
import { signedFetch, adminFetch, expectOk } from '../signedFetch.js';
|
||||||
import { parseEnv, serializeEnv } from '../envFormat.js';
|
import { parseEnv, serializeEnv, buildEnvTemplate, fillEnvTemplate } from '../envFormat.js';
|
||||||
import {
|
import {
|
||||||
ready,
|
ready,
|
||||||
Identity,
|
Identity,
|
||||||
@@ -16,8 +16,13 @@ export async function vaultPush(vaultKey: string, file: string, purge: boolean):
|
|||||||
const { identity, recipientId } = await requireIdentityAndRecipientId();
|
const { identity, recipientId } = await requireIdentityAndRecipientId();
|
||||||
|
|
||||||
if (!fs.existsSync(file)) throw new Error(`file not found: ${file}`);
|
if (!fs.existsSync(file)) throw new Error(`file not found: ${file}`);
|
||||||
const values = parseEnv(fs.readFileSync(file, 'utf8'));
|
const raw = fs.readFileSync(file, 'utf8');
|
||||||
const payload = enc.fromUtf8(JSON.stringify(values));
|
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`));
|
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)' : ''}.`);
|
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();
|
await ready();
|
||||||
const { identity, recipientId } = await requireIdentityAndRecipientId();
|
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');
|
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 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) {
|
if (outFile) {
|
||||||
fs.writeFileSync(outFile, out);
|
fs.writeFileSync(outFile, out);
|
||||||
console.error(`wrote ${Object.keys(values).length} key(s) to ${outFile}${previous ? ' (previous version)' : ''}`);
|
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}`)
|
.map(([k, v]) => `${k}=${/[\s#"']/.test(v) ? JSON.stringify(v) : v}`)
|
||||||
.join('\n') + '\n';
|
.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 === 'overview') return await adminOverview();
|
||||||
|
|
||||||
if (cmd === 'push') return await vaultPush(sub, flag(rest, 'file', '.env')!, boolFlag(rest, 'purge'));
|
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 === 'grant') return await vaultGrant(sub, rest[0], boolFlag(rest, 'read-only'));
|
||||||
if (cmd === 'revoke') return await vaultRevoke(sub, rest[0]);
|
if (cmd === 'revoke') return await vaultRevoke(sub, rest[0]);
|
||||||
if (cmd === 'log') return await vaultLog(sub);
|
if (cmd === 'log') return await vaultLog(sub);
|
||||||
@@ -54,7 +54,9 @@ function printUsage(): void {
|
|||||||
keep identity set-id <recipient-id>
|
keep identity set-id <recipient-id>
|
||||||
|
|
||||||
keep push <vault> [--file .env] [--purge] (--purge: don't retain the outgoing version as rollback)
|
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 grant <vault> <recipient-id> [--read-only]
|
||||||
keep revoke <vault> <recipient-id> (admin — needs KEEP_ADMIN_PASSWORD)
|
keep revoke <vault> <recipient-id> (admin — needs KEEP_ADMIN_PASSWORD)
|
||||||
keep log <vault>
|
keep log <vault>
|
||||||
|
|||||||
Reference in New Issue
Block a user