Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/commands/decryptCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { writeFileSync } from 'fs';
import { join } from 'path';
import { ProjectManager } from '../core/projectManager';
import { FileManager } from '../files/fileManager';
import { dotenvEscape } from './exportCommand';
import {
validateSeedPhrase,
seedPhraseToMasterKey,
Expand Down Expand Up @@ -122,10 +123,12 @@ export class DecryptCommand {
process.exit(0);
}

// Write .env.{branch}.decrypted
// Write .env.{branch}.decrypted. Escape values so multi-line secrets
// (PEM keys, certs) are quoted/`\n`-escaped and survive being re-read by
// dotenv — a bare `KEY=value` line would truncate at the first newline.
const outputFile = `.env.${branch}.decrypted`;
const content = Object.entries(decrypted)
.map(([key, value]) => `${key}=${value}`)
.map(([key, value]) => `${key}=${dotenvEscape(value)}`)
.join('\n');

writeFileSync(join(process.cwd(), outputFile), content + '\n', 'utf-8');
Expand Down
16 changes: 11 additions & 5 deletions src/crypto/deployCrypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,13 @@ export function parseDeployCode(deployCode: string): {
* service_key = HKDF-SHA256(innerBlob, salt=projectId+hex(deployId), info="capy:deploy:service-key", 32)
* DECRYPT_KEY = HKDF-SHA256(projectKey || service_key, salt=deployId, info="capy:deploy:decrypt", 32)
*
* Encrypted with AES-256-GCM(envBlob, DECRYPT_KEY) where envBlob is KEY=value\n lines.
* Encrypted with AES-256-GCM(envBlob, DECRYPT_KEY) where envBlob is a JSON
* object of { KEY: value } pairs. JSON is used (rather than KEY=value\n lines)
* so values may contain newlines, `=`, and `#` and still round-trip byte-for-byte
* — multi-line secrets like PEM private keys would otherwise be truncated at the
* first line and continuation lines containing `=` would mint phantom env vars.
* `decryptSecretsBlob` still accepts the legacy line format for blobs minted by
* older CLI versions.
*
* IMPORTANT: innerBlob MUST be the exact base64 string that was sent to the
* service for KMS-wrapping. Do not recompute innerBlob here — deployInnerWrap
Expand Down Expand Up @@ -105,10 +111,10 @@ export function encryptEnvBlob(
hkdfSync('sha256', combined, deployId, 'capy:deploy:decrypt', 32),
);

const envBlob = Object.entries(envVars)
.map(([k, v]) => `${k}=${v}`)
.join('\n');
const plaintext = Buffer.from(envBlob, 'utf-8');
// JSON encoding round-trips multi-line values (PEM keys, certs, JSON blobs)
// and values containing `=`/`#` faithfully. The decrypt side detects this
// format vs the legacy `KEY=value\n` lines by the leading `{`.
const plaintext = Buffer.from(JSON.stringify(envVars), 'utf-8');

const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv('aes-256-gcm', decryptKey, iv, {
Expand Down
53 changes: 43 additions & 10 deletions src/crypto/deployRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,52 @@ export async function fetchServiceKey(
return body.service_key;
}

/**
* Parses the decrypted env-var plaintext into a { KEY: value } record.
*
* Two formats are accepted:
* - JSON object (current): `{"KEY":"value",...}` — round-trips multi-line
* values and values containing `=`/`#` byte-for-byte.
* - Legacy `KEY=value\n` lines: emitted by CLI versions before multi-line
* support. Kept for backward compatibility so a newer `capy run` can still
* decrypt a SECRETS_BLOB minted by an older `capy deploy`.
*
* The leading `{` discriminates the two: env var names cannot begin with `{`,
* so a legacy blob (which starts with a key name) never collides with JSON.
*/
export function parseEnvPlaintext(text: string): Record<string, string> {
if (text.trimStart().startsWith('{')) {
try {
const obj = JSON.parse(text);
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(obj)) {
result[k] = typeof v === 'string' ? v : String(v);
}
return result;
}
} catch {
// Not valid JSON after all — fall through to legacy line parsing.
}
}

const result: Record<string, string> = {};
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
result[trimmed.substring(0, eqIdx)] = trimmed.substring(eqIdx + 1);
}
return result;
}

/**
* Derives the DECRYPT_KEY = HKDF(PROJECT_KEY || SERVICE_KEY, salt=deployId,
* info="capy:deploy:decrypt") and decrypts the env var blob with AES-256-GCM.
*
* Returns a record of KEY=value pairs parsed from the "KEY1=value1\n..." plaintext.
* Returns a record of KEY=value pairs parsed from the decrypted plaintext
* (JSON object, or legacy `KEY=value\n` lines — see {@link parseEnvPlaintext}).
*/
export function decryptSecretsBlob(
encryptedVars: Buffer,
Expand Down Expand Up @@ -124,13 +165,5 @@ export function decryptSecretsBlob(
decipher.setAuthTag(authTag);
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);

const result: Record<string, string> = {};
for (const line of plaintext.toString('utf-8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
result[trimmed.substring(0, eqIdx)] = trimmed.substring(eqIdx + 1);
}
return result;
return parseEnvPlaintext(plaintext.toString('utf-8'));
}
27 changes: 17 additions & 10 deletions src/files/fileManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,26 +403,33 @@ export class FileManager {
* Creates a snippet-enhanced encrypted value for better usability
*/
private createSnippetWithEncryption(originalValue: string, encryptedValue: string): string {
const valueLength = originalValue.length;
// The snippet pieces are a cosmetic plaintext preview spliced around the
// ciphertext. They MUST stay newline-free: a newline in a leading snippet
// would land in the .env line before the ciphertext, and dotenv truncates a
// value at the first newline — corrupting decryption of any multi-line
// secret (e.g. a PEM key whose first bytes include a newline). Collapse all
// whitespace runs to a single space so the preview is always one line.
const snippetSource = originalValue.replace(/\s+/g, ' ');
const valueLength = snippetSource.length;

if (valueLength <= 4) {
const snippet = originalValue.slice(-1);
const snippet = snippetSource.slice(-1);
return `${encryptedValue}...${snippet}`;
} else if (valueLength <= 8) {
const firstSnippet = originalValue.slice(0, 1);
const lastSnippet = originalValue.slice(-1);
const firstSnippet = snippetSource.slice(0, 1);
const lastSnippet = snippetSource.slice(-1);
return `${firstSnippet}...${encryptedValue}...${lastSnippet}`;
} else if (valueLength <= 16) {
const firstSnippet = originalValue.slice(0, 1);
const lastSnippet = originalValue.slice(-3);
const firstSnippet = snippetSource.slice(0, 1);
const lastSnippet = snippetSource.slice(-3);
return `${firstSnippet}...${encryptedValue}...${lastSnippet}`;
} else if (valueLength <= 24) {
const firstSnippet = originalValue.slice(0, 2);
const lastSnippet = originalValue.slice(-6);
const firstSnippet = snippetSource.slice(0, 2);
const lastSnippet = snippetSource.slice(-6);
return `${firstSnippet}...${encryptedValue}...${lastSnippet}`;
} else {
const firstSnippet = originalValue.slice(0, 4);
const lastSnippet = originalValue.slice(-6);
const firstSnippet = snippetSource.slice(0, 4);
const lastSnippet = snippetSource.slice(-6);
return `${firstSnippet}...${encryptedValue}...${lastSnippet}`;
}
}
Expand Down
4 changes: 3 additions & 1 deletion src/index-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,8 +424,10 @@ program
}

const { writeFileSync } = await import('fs');
const { dotenvEscape } = await import('./commands/exportCommand');
// Escape so multi-line secrets survive being re-read by dotenv.
const content = Object.entries(decrypted)
.map(([key, value]) => `${key}=${value}`)
.map(([key, value]) => `${key}=${dotenvEscape(value as string)}`)
.join('\n');

writeFileSync(envPath, content + '\n', 'utf-8');
Expand Down
88 changes: 81 additions & 7 deletions src/ui/editScreen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ const CLEAR_SCREEN = `${ESC}[2J`;
const CLEAR_EOL = `${ESC}[K`;
const ENTER_ALT_SCREEN = `${ESC}[?1049h`;
const EXIT_ALT_SCREEN = `${ESC}[?1049l`;
// Bracketed paste: when enabled the terminal wraps pasted text in these
// markers, so we can append a multi-line paste (e.g. a PEM private key)
// verbatim instead of treating its embedded newlines as Enter/commit.
const ENABLE_BRACKETED_PASTE = `${ESC}[?2004h`;
const DISABLE_BRACKETED_PASTE = `${ESC}[?2004l`;
const PASTE_START = `${ESC}[200~`;
const PASTE_END = `${ESC}[201~`;
const INVERSE = `${ESC}[7m`;
const RESET = `${ESC}[0m`;
const DIM = `${ESC}[90m`;
Expand Down Expand Up @@ -63,6 +70,33 @@ export function classifyLocalRow(
: { status: 'local', updatedLabel: 'uncommitted' };
}

/**
* Normalizes bracketed-paste content for storage in an edit buffer. Pasted
* line breaks are kept (so multi-line secrets like PEM keys survive), CRLF/CR
* are folded to LF, and other control characters (besides tab) are dropped so a
* stray escape sequence in the paste can't corrupt the value or the terminal.
*/
export function sanitizePastedText(raw: string): string {
let out = '';
for (const ch of raw.replace(/\r\n?/g, '\n')) {
const code = ch.charCodeAt(0);
if (code === 0x0a || code === 0x09 || (code >= 0x20 && code !== 0x7f)) {
out += ch;
}
}
return out;
}

/**
* Collapses a (possibly multi-line) value to a single visible line for the
* single-row TUI table: newlines render as a ↵ marker and tabs as a space.
* Length is preserved 1:1 so callers that pan/clip by character offset stay
* correct.
*/
export function renderInlineValue(value: string): string {
return value.replace(/\n/g, '↵').replace(/\t/g, ' ');
}

export class EditScreen {
private state: EditState = { projectName: '', branch: '', rows: [], remoteAvailable: false };
private ctx: EditContext | null = null;
Expand All @@ -78,6 +112,9 @@ export class EditScreen {
private quitPrompt: 'commit' | null = null;
private popupOpen = false;
private popupPanOffset = 0;
// Accumulates raw stdin between PASTE_START and PASTE_END markers when a
// bracketed paste spans multiple `data` chunks. null when not mid-paste.
private pasteBuffer: string | null = null;

run(state: EditState, ctx: EditContext): Promise<void> {
this.state = state;
Expand All @@ -94,7 +131,7 @@ export class EditScreen {
this.popupPanOffset = 0;

return new Promise<void>((resolve) => {
process.stdout.write(ENTER_ALT_SCREEN + HIDE_CURSOR);
process.stdout.write(ENTER_ALT_SCREEN + HIDE_CURSOR + ENABLE_BRACKETED_PASTE);
if (process.stdin.isTTY) process.stdin.setRawMode(true);
process.stdin.resume();

Expand All @@ -118,7 +155,7 @@ export class EditScreen {
private cleanup(): void {
if (this.cleanedUp) return;
this.cleanedUp = true;
process.stdout.write(SHOW_CURSOR + EXIT_ALT_SCREEN);
process.stdout.write(DISABLE_BRACKETED_PASTE + SHOW_CURSOR + EXIT_ALT_SCREEN);
if (process.stdin.isTTY) process.stdin.setRawMode(false);
process.stdin.pause();
if (this.onDataHandler) process.stdin.removeListener('data', this.onDataHandler);
Expand All @@ -128,6 +165,15 @@ export class EditScreen {
private handleKeypress(data: Buffer, resolve: () => void): void {
const key = data.toString();

// Bracketed paste arrives as ESC[200~ <content> ESC[201~, possibly split
// across data chunks. Intercept it before any key dispatch so pasted text
// (which may contain 'q', newlines, etc.) can never trigger quit/commit or
// navigation — its content is only ever appended to an active edit buffer.
if (this.pasteBuffer !== null || key.includes(PASTE_START)) {
this.consumePaste(key);
return;
}

// Ctrl-C exits unconditionally
if (key === '\x03') {
this.cleanup();
Expand Down Expand Up @@ -282,6 +328,30 @@ export class EditScreen {
this.draw();
}

// Accumulates a bracketed paste across data chunks and, once the closing
// marker arrives, appends the pasted text to the active edit buffer verbatim
// (newlines preserved). Pasted outside of edit mode, the content is dropped.
private consumePaste(chunk: string): void {
this.pasteBuffer = (this.pasteBuffer ?? '') + chunk;
const start = this.pasteBuffer.indexOf(PASTE_START);
if (start === -1) {
this.pasteBuffer = null;
return;
}
const end = this.pasteBuffer.indexOf(PASTE_END, start + PASTE_START.length);
if (end === -1) return; // paste spans more chunks — wait for the rest

const pasted = this.pasteBuffer.substring(start + PASTE_START.length, end);
this.pasteBuffer = null;

if (!this.editing) return;
const appended = sanitizePastedText(pasted);
if (appended) {
this.editing.buffer += appended;
this.draw();
}
}

private handleEditKey(key: string): void {
if (!this.editing) return;

Expand Down Expand Up @@ -615,7 +685,7 @@ export class EditScreen {
// and revealed state with horizontal panning when the value overflows.
private renderValueField(row: EditRow, width: number): string {
if (this.editing && this.editing.key === row.key) {
const display = `> ${this.editing.buffer}_`;
const display = `> ${renderInlineValue(this.editing.buffer)}_`;
if (display.length <= width) return display;
// Keep the cursor (end of buffer) visible — clip from the left.
return '…' + display.slice(display.length - width + 1);
Expand All @@ -626,9 +696,13 @@ export class EditScreen {
return this.maskedSnippet(row);
}

const value = row.localValue ?? row.remoteValue;
if (value === undefined) return `${DIM}(no value)${RESET}`;
if (value === '') return `${DIM}(empty)${RESET}`;
const rawValue = row.localValue ?? row.remoteValue;
if (rawValue === undefined) return `${DIM}(no value)${RESET}`;
if (rawValue === '') return `${DIM}(empty)${RESET}`;
// Collapse newlines/tabs so a revealed multi-line value (e.g. a PEM key)
// stays on one row. renderInlineValue preserves length 1:1, so the panning
// math below remains correct.
const value = renderInlineValue(rawValue);

if (value.length <= width) {
this.popupPanOffset = 0;
Expand Down Expand Up @@ -694,6 +768,6 @@ export class EditScreen {
private maskedSnippet(row: EditRow): string {
const value = row.localValue ?? row.remoteValue;
if (value === undefined || value === '') return NO_VALUE;
return formatSnippet(value);
return renderInlineValue(formatSnippet(value));
}
}
Loading
Loading