From 33c500c29ceb3cd1c1bdae2bb9d0a3fd10d515b2 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Mon, 19 Jan 2026 13:35:37 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E2=9C=A8=20add=20stash=20CLI=20too?= =?UTF-8?q?l=20for=20managing=20encrypted=20secrets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add beautiful CLI tool using Stricli framework with color-coded output - Implement Stash class for high-level secrets management API - Support set, get, list, delete, and getMany operations - Use CS_* environment variables (CS_WORKSPACE_CRN, CS_CLIENT_ID, etc.) - Add shorthand aliases (-n, -e, -V) for command flags - Configure tsup to build CLI as executable binary - Export stash module from package.json --- packages/protect/package.json | 10 + packages/protect/src/bin/stash.ts | 455 +++++++++++++++++++++++++++ packages/protect/src/stash/index.ts | 459 ++++++++++++++++++++++++++++ packages/protect/tsup.config.ts | 34 ++- pnpm-lock.yaml | 8 + 5 files changed, 958 insertions(+), 8 deletions(-) create mode 100644 packages/protect/src/bin/stash.ts create mode 100644 packages/protect/src/stash/index.ts diff --git a/packages/protect/package.json b/packages/protect/package.json index fa31870c..af42481b 100644 --- a/packages/protect/package.json +++ b/packages/protect/package.json @@ -20,6 +20,9 @@ "license": "MIT", "author": "CipherStash ", "type": "module", + "bin": { + "stash": "./dist/bin/stash.js" + }, "main": "./dist/index.cjs", "types": "./dist/index.d.ts", "exports": { @@ -37,10 +40,16 @@ "types": "./dist/identify/index.d.ts", "import": "./dist/identify/index.js", "require": "./dist/identify/index.cjs" + }, + "./stash": { + "types": "./dist/stash/index.d.ts", + "import": "./dist/stash/index.js", + "require": "./dist/stash/index.cjs" } }, "scripts": { "build": "tsup", + "postbuild": "chmod +x ./dist/bin/stash.js", "dev": "tsup --watch", "test": "vitest run", "release": "tsup" @@ -62,6 +71,7 @@ "@byteslice/result": "^0.2.0", "@cipherstash/protect-ffi": "0.19.0", "@cipherstash/schema": "workspace:*", + "@stricli/core": "^1.2.5", "zod": "^3.24.2" }, "optionalDependencies": { diff --git a/packages/protect/src/bin/stash.ts b/packages/protect/src/bin/stash.ts new file mode 100644 index 00000000..96106afe --- /dev/null +++ b/packages/protect/src/bin/stash.ts @@ -0,0 +1,455 @@ +import { + type CommandContext, + buildApplication, + buildCommand, + buildRouteMap, + run, +} from '@stricli/core' +import { Stash } from '../stash/index.js' + +// ANSI color codes for beautiful terminal output +const colors = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + green: '\x1b[32m', + red: '\x1b[31m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', + magenta: '\x1b[35m', +} + +const style = { + success: (text: string) => + `${colors.green}${colors.bold}✓${colors.reset} ${colors.green}${text}${colors.reset}`, + error: (text: string) => + `${colors.red}${colors.bold}✗${colors.reset} ${colors.red}${text}${colors.reset}`, + info: (text: string) => + `${colors.blue}${colors.bold}ℹ${colors.reset} ${colors.blue}${text}${colors.reset}`, + warning: (text: string) => + `${colors.yellow}${colors.bold}⚠${colors.reset} ${colors.yellow}${text}${colors.reset}`, + title: (text: string) => `${colors.bold}${colors.cyan}${text}${colors.reset}`, + label: (text: string) => `${colors.dim}${text}${colors.reset}`, + value: (text: string) => `${colors.bold}${text}${colors.reset}`, + bullet: () => `${colors.green}•${colors.reset}`, +} + +/** + * Get configuration from environment variables + */ +function getConfig(environment: string): Stash['config'] { + const workspaceCRN = process.env.CS_WORKSPACE_CRN + const clientId = process.env.CS_CLIENT_ID + const clientKey = process.env.CS_CLIENT_KEY + const apiKey = process.env.CS_CLIENT_ACCESS_KEY + const accessKey = process.env.CS_ACCESS_KEY + + const missing: string[] = [] + if (!workspaceCRN) missing.push('CS_WORKSPACE_CRN') + if (!clientId) missing.push('CS_CLIENT_ID') + if (!clientKey) missing.push('CS_CLIENT_KEY') + if (!apiKey) missing.push('CS_CLIENT_ACCESS_KEY') + + if (missing.length > 0) { + console.error( + style.error( + `Missing required environment variables: ${missing.join(', ')}`, + ), + ) + console.error( + `\n${style.info('Please set the following environment variables:')}`, + ) + for (const varName of missing) { + console.error(` ${style.bullet()} ${varName}`) + } + process.exit(1) + } + + if (!workspaceCRN || !clientId || !clientKey || !apiKey) { + // This should never happen due to the check above, but TypeScript needs it + throw new Error('Missing required configuration') + } + + return { + workspaceCRN, + clientId, + clientKey, + apiKey, + accessKey, + environment, + } +} + +/** + * Create a Stash instance with proper error handling + */ +function createStash(environment: string): Stash { + const config = getConfig(environment) + return new Stash(config) +} + +/** + * Set command - Store an encrypted secret + */ +const setCommand = buildCommand({ + func: async (flags: { name: string; value: string; environment: string }) => { + const { name, value, environment } = flags + const stash = createStash(environment) + + console.log( + `${style.info(`Encrypting and storing secret "${name}" in environment "${environment}"...`)}`, + ) + + const result = await stash.set(name, value) + if (result.failure) { + console.error( + style.error(`Failed to set secret: ${result.failure.message}`), + ) + process.exit(1) + } + + console.log( + style.success( + `Secret "${name}" stored successfully in environment "${environment}"`, + ), + ) + }, + parameters: { + flags: { + name: { + kind: 'parsed', + parse: String, + brief: 'Name of the secret to store', + }, + value: { + kind: 'parsed', + parse: String, + brief: 'Plaintext value to encrypt and store', + }, + environment: { + kind: 'parsed', + parse: String, + brief: 'Environment name (e.g., production, staging, development)', + }, + }, + aliases: { + n: 'name', + V: 'value', + e: 'environment', + }, + }, + docs: { + brief: 'Store an encrypted secret in CipherStash', + fullDescription: ` +Store a secret value that will be encrypted locally before being sent to the CipherStash API. +The secret is encrypted end-to-end, ensuring your plaintext never leaves your machine unencrypted. + +Examples: + stash secrets set --name DATABASE_URL --value "postgres://..." --environment production + stash secrets set -n DATABASE_URL -V "postgres://..." -e production + stash secrets set --name API_KEY --value "sk-123..." --environment staging + `.trim(), + }, +}) + +/** + * Get command - Retrieve and decrypt a secret + */ +const getCommand = buildCommand({ + func: async (flags: { name: string; environment: string }) => { + const { name, environment } = flags + const stash = createStash(environment) + + console.log( + `${style.info(`Retrieving secret "${name}" from environment "${environment}"...`)}`, + ) + + const result = await stash.get(name) + if (result.failure) { + console.error( + style.error(`Failed to get secret: ${result.failure.message}`), + ) + process.exit(1) + } + + console.log(`\n${style.title('Secret Value:')}`) + console.log(style.value(result.data)) + }, + parameters: { + flags: { + name: { + kind: 'parsed', + parse: String, + brief: 'Name of the secret to retrieve', + }, + environment: { + kind: 'parsed', + parse: String, + brief: 'Environment name (e.g., production, staging, development)', + }, + }, + aliases: { + n: 'name', + e: 'environment', + }, + }, + docs: { + brief: 'Retrieve and decrypt a secret from CipherStash', + fullDescription: ` +Retrieve a secret from CipherStash and decrypt it locally. The secret value is decrypted +on your machine, ensuring end-to-end security. + +Examples: + stash secrets get --name DATABASE_URL --environment production + stash secrets get -n DATABASE_URL -e production + stash secrets get --name API_KEY --environment staging + `.trim(), + }, +}) + +/** + * List command - List all secrets in an environment + */ +const listCommand = buildCommand({ + func: async (flags: { environment: string }) => { + const { environment } = flags + const stash = createStash(environment) + + console.log( + `${style.info(`Listing secrets in environment "${environment}"...`)}`, + ) + + const result = await stash.list() + if (result.failure) { + console.error( + style.error(`Failed to list secrets: ${result.failure.message}`), + ) + process.exit(1) + } + + if (result.data.length === 0) { + console.log( + `\n${style.warning(`No secrets found in environment "${environment}"`)}`, + ) + return + } + + console.log(`\n${style.title(`Secrets in environment "${environment}":`)}`) + console.log('') + + for (const secret of result.data) { + const name = style.value(secret.name) + const metadata: string[] = [] + if (secret.createdAt) { + metadata.push( + `${style.label('created:')} ${new Date(secret.createdAt).toLocaleString()}`, + ) + } + if (secret.updatedAt) { + metadata.push( + `${style.label('updated:')} ${new Date(secret.updatedAt).toLocaleString()}`, + ) + } + + const metaStr = + metadata.length > 0 + ? ` ${colors.dim}(${metadata.join(', ')})${colors.reset}` + : '' + console.log(` ${style.bullet()} ${name}${metaStr}`) + } + + console.log('') + console.log( + style.label( + `Total: ${result.data.length} secret${result.data.length === 1 ? '' : 's'}`, + ), + ) + }, + parameters: { + flags: { + environment: { + kind: 'parsed', + parse: String, + brief: 'Environment name (e.g., production, staging, development)', + }, + }, + aliases: { + e: 'environment', + }, + }, + docs: { + brief: 'List all secrets in an environment', + fullDescription: ` +List all secrets stored in the specified environment. Only secret names and metadata +are returned; values remain encrypted and are not displayed. + +Examples: + stash secrets list --environment production + stash secrets list -e production + stash secrets list --environment staging + `.trim(), + }, +}) + +/** + * Delete command - Delete a secret from the vault + */ +const deleteCommand = buildCommand({ + func: async (flags: { name: string; environment: string }) => { + const { name, environment } = flags + const stash = createStash(environment) + + console.log( + `${style.warning(`Deleting secret "${name}" from environment "${environment}"...`)}`, + ) + + const result = await stash.delete(name) + if (result.failure) { + console.error( + style.error(`Failed to delete secret: ${result.failure.message}`), + ) + process.exit(1) + } + + console.log( + style.success( + `Secret "${name}" deleted successfully from environment "${environment}"`, + ), + ) + }, + parameters: { + flags: { + name: { + kind: 'parsed', + parse: String, + brief: 'Name of the secret to delete', + }, + environment: { + kind: 'parsed', + parse: String, + brief: 'Environment name (e.g., production, staging, development)', + }, + }, + aliases: { + n: 'name', + e: 'environment', + }, + }, + docs: { + brief: 'Delete a secret from CipherStash', + fullDescription: ` +Permanently delete a secret from the specified environment. This action cannot be undone. + +Examples: + stash secrets delete --name DATABASE_URL --environment production + stash secrets delete -n DATABASE_URL -e production + stash secrets delete --name API_KEY --environment staging + `.trim(), + }, +}) + +/** + * Secrets route map - Groups all secret management commands + */ +const secretsRouteMap = buildRouteMap({ + routes: { + set: setCommand, + get: getCommand, + list: listCommand, + delete: deleteCommand, + }, + docs: { + brief: 'Manage encrypted secrets in CipherStash', + fullDescription: ` +The secrets command group provides operations for managing encrypted secrets stored in CipherStash. +All secrets are encrypted locally before being sent to the API, ensuring end-to-end encryption. + +Available Commands: + set Store an encrypted secret + get Retrieve and decrypt a secret + list List all secrets in an environment + delete Delete a secret from the vault + +Environment Variables: + CS_WORKSPACE_CRN CipherStash workspace CRN (required) + CS_CLIENT_ID CipherStash client ID (required) + CS_CLIENT_KEY CipherStash client key (required) + CS_CLIENT_ACCESS_KEY CipherStash client access key (required) + +Examples: + stash secrets set --name DATABASE_URL --value "postgres://..." --environment production + stash secrets set -n DATABASE_URL -V "postgres://..." -e production + stash secrets get --name DATABASE_URL --environment production + stash secrets get -n DATABASE_URL -e production + stash secrets list --environment production + stash secrets list -e production + stash secrets delete --name DATABASE_URL --environment production + stash secrets delete -n DATABASE_URL -e production + `.trim(), + }, +}) + +/** + * Root command - Entry point for the CLI + */ +const rootRouteMap = buildRouteMap({ + routes: { + secrets: secretsRouteMap, + }, + docs: { + brief: 'CipherStash Protect - Encrypted secrets management', + fullDescription: ` +CipherStash Protect CLI + +Manage encrypted secrets with end-to-end encryption. Secrets are encrypted locally +before being sent to the CipherStash API, ensuring your plaintext never leaves +your machine unencrypted. + +Quick Start: + 1. Set required environment variables (CS_WORKSPACE_CRN, CS_CLIENT_ID, etc.) + 2. Use 'stash secrets set' to store your first secret + 3. Use 'stash secrets get' to retrieve secrets when needed + +Commands: + secrets Manage encrypted secrets + +Run 'stash --help' for more information about a command. + `.trim(), + }, +}) + +/** + * Build the CLI application + */ +const app = buildApplication(rootRouteMap, { + name: 'stash', + versionInfo: { currentVersion: '10.2.1' }, + scanner: { caseStyle: 'allow-kebab-for-camel' }, +}) + +/** + * Main entry point + */ +async function main(): Promise { + try { + await run(app, process.argv.slice(2), { + process, + async forCommand() { + return { + process, + } + }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(style.error(`Unexpected error: ${message}`)) + process.exit(1) + } +} + +void main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + console.error(style.error(`Fatal error: ${message}`)) + process.exit(1) +}) diff --git a/packages/protect/src/stash/index.ts b/packages/protect/src/stash/index.ts new file mode 100644 index 00000000..12cd8f4d --- /dev/null +++ b/packages/protect/src/stash/index.ts @@ -0,0 +1,459 @@ +import type { Result } from '@byteslice/result' +import { csColumn, csTable } from '@cipherstash/schema' +import { type ProtectClient, encryptedToPgComposite, protect } from '../index' +import type { Encrypted } from '../types' + +export type SecretName = string +export type SecretValue = string + +/** + * Configuration options for initializing the Stash client + */ +export interface StashConfig { + workspaceCRN: string + clientId: string + clientKey: string + environment: string + apiKey: string + accessKey?: string +} + +/** + * Secret metadata returned from the API + */ +export interface SecretMetadata { + id?: string + name: string + environment: string + createdAt?: string + updatedAt?: string +} + +/** + * API response for listing secrets + */ +export interface ListSecretsResponse { + environment: string + secrets: SecretMetadata[] +} + +/** + * API response for getting a secret + */ +export interface GetSecretResponse { + name: string + environment: string + encryptedValue: { + data: Encrypted + } + createdAt?: string + updatedAt?: string +} + +export interface DecryptedSecretResponse { + name: string + environment: string + value: string + createdAt?: string + updatedAt?: string +} + +/** + * The Stash client provides a high-level API for managing encrypted secrets + * stored in CipherStash. Secrets are encrypted locally before being sent to + * the API, ensuring end-to-end encryption. + */ +export class Stash { + private protectClient: ProtectClient | null = null + private config: StashConfig + private readonly apiBaseUrl = + process.env.STASH_API_URL || 'https://getstash.sh/api/secrets' + private readonly secretsSchema = csTable('secrets', { + value: csColumn('value'), + }) + + /** + * Extracts the workspace ID from a CRN string. + * CRN format: crn:region.aws:ID + * + * @param crn The CRN string to extract from + * @returns The workspace ID portion of the CRN + */ + private extractWorkspaceIdFromCrn(crn: string): string { + const match = crn.match(/crn:[^:]+:([^:]+)$/) + if (!match) { + throw new Error('Invalid CRN format') + } + return match[1] + } + + constructor(config: StashConfig) { + this.config = config + } + + /** + * Initialize the Stash client and underlying Protect client + */ + private async ensureInitialized(): Promise { + if (this.protectClient) { + return + } + + this.protectClient = await protect({ + schemas: [this.secretsSchema], + workspaceCrn: this.config.workspaceCRN, + clientId: this.config.clientId, + clientKey: this.config.clientKey, + accessKey: this.config.apiKey, + keyset: { + name: this.config.environment, + }, + }) + } + + /** + * Get the authorization header for API requests + */ + private getAuthHeader(): string { + return `Bearer ${this.config.apiKey}` + } + + /** + * Make an API request with error handling + */ + private async apiRequest( + method: string, + path: string, + body?: unknown, + ): Promise> { + try { + const url = `${this.apiBaseUrl}${path}` + const headers: Record = { + 'Content-Type': 'application/json', + Authorization: this.getAuthHeader(), + } + + const response = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }) + + if (!response.ok) { + const errorText = await response.text() + let errorMessage = `API request failed with status ${response.status}` + try { + const errorJson = JSON.parse(errorText) + errorMessage = errorJson.message || errorMessage + } catch { + errorMessage = errorText || errorMessage + } + + return { + failure: { + type: 'ApiError', + message: errorMessage, + }, + } + } + + const data = await response.json() + return { data } + } catch (error) { + return { + failure: { + type: 'NetworkError', + message: + error instanceof Error + ? error.message + : 'Unknown network error occurred', + }, + } + } + } + + /** + * Store an encrypted secret in the vault. + * The value is encrypted locally before being sent to the API. + * + * @param name - The name of the secret + * @param value - The plaintext value to encrypt and store + * @returns A Result indicating success or failure + * + * @example + * ```typescript + * const stash = new Stash({ ... }) + * const result = await stash.set('DATABASE_URL', 'postgres://user:pass@localhost:5432/mydb') + * if (result.failure) { + * console.error('Failed to set secret:', result.failure.message) + * } + * ``` + */ + async set( + name: SecretName, + value: SecretValue, + ): Promise> { + await this.ensureInitialized() + + if (!this.protectClient) { + return { + failure: { + type: 'ClientError', + message: 'Failed to initialize Protect client', + }, + } + } + + // Encrypt the value locally + const encryptResult = await this.protectClient.encrypt(value, { + column: this.secretsSchema.value, + table: this.secretsSchema, + }) + + if (encryptResult.failure) { + return { + failure: { + type: 'EncryptionError', + message: encryptResult.failure.message, + }, + } + } + + // Extract workspaceId from CRN + const workspaceId = this.extractWorkspaceIdFromCrn(this.config.workspaceCRN) + + // Send encrypted value to API + return await this.apiRequest('POST', '/set', { + workspaceId, + environment: this.config.environment, + name, + encryptedValue: encryptedToPgComposite(encryptResult.data), + }) + } + + /** + * Retrieve and decrypt a secret from the vault. + * The secret is decrypted locally after retrieval. + * + * @param name - The name of the secret to retrieve + * @returns A Result containing the decrypted value or an error + * + * @example + * ```typescript + * const stash = new Stash({ ... }) + * const result = await stash.get('DATABASE_URL') + * if (result.failure) { + * console.error('Failed to get secret:', result.failure.message) + * } else { + * console.log('Secret value:', result.data) + * } + * ``` + */ + async get( + name: SecretName, + ): Promise> { + await this.ensureInitialized() + + if (!this.protectClient) { + return { + failure: { + type: 'ClientError', + message: 'Failed to initialize Protect client', + }, + } + } + + // Extract workspaceId from CRN + const workspaceId = this.extractWorkspaceIdFromCrn(this.config.workspaceCRN) + + // Fetch encrypted value from API + const apiResult = await this.apiRequest('POST', '/get', { + workspaceId, + environment: this.config.environment, + name, + }) + + if (apiResult.failure) { + return apiResult + } + + // Decrypt the value locally + const decryptResult = await this.protectClient.decrypt( + apiResult.data.encryptedValue.data, + ) + + if (decryptResult.failure) { + return { + failure: { + type: 'DecryptionError', + message: decryptResult.failure.message, + }, + } + } + + if (typeof decryptResult.data !== 'string') { + return { + failure: { + type: 'DecryptionError', + message: 'Decrypted value is not a string', + }, + } + } + + return { data: decryptResult.data } + } + + /** + * Retrieve and decrypt many secrets from the vault. + * The secrets are decrypted locally after retrieval. + * This method only triggers a single network request to the ZeroKMS. + * + * @param names - The names of the secrets to retrieve + * @returns A Result containing an object mapping secret names to their decrypted values + * + * @example + * ```typescript + * const stash = new Stash({ ... }) + * const result = await stash.getMany(['DATABASE_URL', 'API_KEY']) + * if (result.failure) { + * console.error('Failed to get secrets:', result.failure.message) + * } else { + * const dbUrl = result.data.DATABASE_URL // Access by name + * const apiKey = result.data.API_KEY + * } + * ``` + */ + async getMany( + names: SecretName[], + ): Promise< + Result, { type: string; message: string }> + > { + await this.ensureInitialized() + + if (!this.protectClient) { + return { + failure: { + type: 'ClientError', + message: 'Failed to initialize Protect client', + }, + } + } + + // Extract workspaceId from CRN + const workspaceId = this.extractWorkspaceIdFromCrn(this.config.workspaceCRN) + + // Fetch encrypted value from API + const apiResult = await this.apiRequest( + 'POST', + '/get-many', + { + workspaceId, + environment: this.config.environment, + names, + }, + ) + + if (apiResult.failure) { + return apiResult + } + + const dataToDecrypt = apiResult.data.map((item) => ({ + name: item.name, + value: item.encryptedValue.data, + })) + + const decryptResult = + await this.protectClient.bulkDecryptModels(dataToDecrypt) + + if (decryptResult.failure) { + return { + failure: { + type: 'DecryptionError', + message: decryptResult.failure.message, + }, + } + } + + console.log('Decrypt result:', JSON.stringify(decryptResult.data, null, 2)) + + // Transform array of decrypted secrets into an object keyed by secret name + const decryptedSecrets = + decryptResult.data as unknown as DecryptedSecretResponse[] + const secretsMap: Record = {} + + for (const secret of decryptedSecrets) { + if (secret.name && secret.value) { + secretsMap[secret.name] = secret.value + } + } + + return { data: secretsMap } + } + + /** + * List all secrets in the environment. + * Only names and metadata are returned; values remain encrypted. + * + * @returns A Result containing the list of secrets or an error + * + * @example + * ```typescript + * const stash = new Stash({ ... }) + * const result = await stash.list() + * if (result.failure) { + * console.error('Failed to list secrets:', result.failure.message) + * } else { + * console.log('Secrets:', result.data) + * } + * ``` + */ + async list(): Promise< + Result + > { + // Extract workspaceId from CRN + const workspaceId = this.extractWorkspaceIdFromCrn(this.config.workspaceCRN) + + const apiResult = await this.apiRequest( + 'POST', + '/list', + { + workspaceId, + environment: this.config.environment, + }, + ) + + if (apiResult.failure) { + return apiResult + } + + return { data: apiResult.data.secrets } + } + + /** + * Delete a secret from the vault. + * + * @param name - The name of the secret to delete + * @returns A Result indicating success or failure + * + * @example + * ```typescript + * const stash = new Stash({ ... }) + * const result = await stash.delete('DATABASE_URL') + * if (result.failure) { + * console.error('Failed to delete secret:', result.failure.message) + * } + * ``` + */ + async delete( + name: SecretName, + ): Promise> { + // Extract workspaceId from CRN + const workspaceId = this.extractWorkspaceIdFromCrn(this.config.workspaceCRN) + + return await this.apiRequest('POST', '/delete', { + workspaceId, + environment: this.config.environment, + name, + }) + } +} diff --git a/packages/protect/tsup.config.ts b/packages/protect/tsup.config.ts index a81137ee..59390bbe 100644 --- a/packages/protect/tsup.config.ts +++ b/packages/protect/tsup.config.ts @@ -1,10 +1,28 @@ import { defineConfig } from 'tsup' -export default defineConfig({ - entry: ['src/index.ts', 'src/client.ts', 'src/identify/index.ts'], - format: ['cjs', 'esm'], - sourcemap: true, - dts: true, - target: 'es2022', - tsconfig: './tsconfig.json', -}) +export default defineConfig([ + { + entry: [ + 'src/index.ts', + 'src/client.ts', + 'src/identify/index.ts', + 'src/stash/index.ts', + ], + format: ['cjs', 'esm'], + sourcemap: true, + dts: true, + target: 'es2022', + tsconfig: './tsconfig.json', + }, + { + entry: ['src/bin/stash.ts'], + outDir: 'dist/bin', + format: ['esm'], + target: 'es2022', + banner: { + js: '#!/usr/bin/env node', + }, + dts: false, + sourcemap: true, + }, +]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3231b7d7..3912a3b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -517,6 +517,9 @@ importers: '@cipherstash/schema': specifier: workspace:* version: link:../schema + '@stricli/core': + specifier: ^1.2.5 + version: 1.2.5 zod: specifier: ^3.24.2 version: 3.25.76 @@ -2877,6 +2880,9 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@stricli/core@1.2.5': + resolution: {integrity: sha512-+afyztQW7fwWkqmU2WQZbdc3LjnZThWYdtE0l+hykZ1Rvy7YGxZSvsVCS/wZ/2BNv117pQ9TU1GZZRIcPnB4tw==} + '@supabase/auth-js@2.89.0': resolution: {integrity: sha512-wiWZdz8WMad8LQdJMWYDZ2SJtZP5MwMqzQq3ehtW2ngiI3UTgbKiFrvMUUS3KADiVlk4LiGfODB2mrYx7w2f8w==} engines: {node: '>=20.0.0'} @@ -9130,6 +9136,8 @@ snapshots: '@standard-schema/utils@0.3.0': {} + '@stricli/core@1.2.5': {} + '@supabase/auth-js@2.89.0': dependencies: tslib: 2.8.1 From a1fce2be0059105bceb7d514419a623207535699 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Mon, 19 Jan 2026 13:36:55 -0700 Subject: [PATCH 2/3] chore: changeset --- .changeset/sunny-ants-watch.md | 5 + .../aws-kms-vs-cipherstash-comparison.md | 466 ++++++++++++++++++ .../protect/__tests__/backward-compat.test.ts | 2 +- 3 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 .changeset/sunny-ants-watch.md create mode 100644 docs/concepts/aws-kms-vs-cipherstash-comparison.md diff --git a/.changeset/sunny-ants-watch.md b/.changeset/sunny-ants-watch.md new file mode 100644 index 00000000..436a1301 --- /dev/null +++ b/.changeset/sunny-ants-watch.md @@ -0,0 +1,5 @@ +--- +"@cipherstash/protect": minor +--- + +Add Stash interface and CLI tool. diff --git a/docs/concepts/aws-kms-vs-cipherstash-comparison.md b/docs/concepts/aws-kms-vs-cipherstash-comparison.md new file mode 100644 index 00000000..d0a52f19 --- /dev/null +++ b/docs/concepts/aws-kms-vs-cipherstash-comparison.md @@ -0,0 +1,466 @@ +# Why Protect.js Makes Encryption Simple: A Comparison with AWS KMS + +Encrypting data shouldn't require managing binary buffers, base64 encoding, key ARNs, or building custom search solutions. Protect.js eliminates these complexities, giving you encryption that "just works" with a developer-friendly API. + +## The Simple Truth: Encrypting a Value + +Let's start with the most basic operation—encrypting a single value. Here's what it takes with each solution: + +### AWS KMS: Manual Work Required + +```typescript +import { KMSClient, EncryptCommand } from '@aws-sdk/client-kms'; + +// Step 1: Initialize client with region +const client = new KMSClient({ region: 'us-west-2' }); + +// Step 2: Define your key ARN (must be configured separately) +const keyId = 'arn:aws:kms:us-west-2:123456789012:key/abcd1234-efgh-5678-ijkl-9012mnopqrst'; + +// Step 3: Write a wrapper function to handle all the manual work +async function encryptWithKMS(plaintext: string): Promise { + try { + // Step 4: Convert string to Buffer + const command = new EncryptCommand({ + KeyId: keyId, // Must specify key for every operation + Plaintext: Buffer.from(plaintext), + }); + + // Step 5: Send command and get binary response + const response = await client.send(command); + + // Step 6: Handle binary CiphertextBlob (Uint8Array) + const ciphertext = response.CiphertextBlob; + + // Step 7: Manually encode to base64 for storage + const base64Ciphertext = Buffer.from(ciphertext).toString('base64'); + + return base64Ciphertext; + } catch (error) { + // Step 8: Handle errors manually + console.error('Error encrypting data:', error); + throw error; + } +} + +// Usage: 8 steps, manual encoding, try/catch required +const encrypted = await encryptWithKMS('secret@squirrel.example'); +``` + +**What you're managing:** +- ❌ Key ARNs for every operation +- ❌ Binary buffer conversions +- ❌ Base64 encoding/decoding +- ❌ Manual error handling with try/catch +- ❌ Region configuration +- ❌ AWS credential setup + +### Protect.js: One Simple Call + +```typescript +import { protect, csTable, csColumn } from '@cipherstash/protect'; + +// One-time setup: Define your schema +const users = csTable('users', { + email: csColumn('email'), +}); + +// One-time setup: Initialize client +const protectClient = await protect({ + schemas: [users], +}); + +// Encrypt: One line, no manual encoding, no key management +const encryptResult = await protectClient.encrypt( + 'secret@squirrel.example', + { column: users.email, table: users } +); + +// Type-safe error handling +if (encryptResult.failure) { + throw new Error(encryptResult.failure.message); +} + +// Done! Returns JSON payload ready for database storage +const ciphertext = encryptResult.data; +``` + +**What you get:** +- ✅ No key management (handled by ZeroKMS) +- ✅ No binary conversions +- ✅ No base64 encoding +- ✅ Type-safe Result-based error handling +- ✅ JSON payload ready for storage +- ✅ Zero-knowledge encryption by default + +## Decryption: The Same Story + +### AWS KMS: More Manual Work + +```typescript +import { KMSClient, DecryptCommand } from '@aws-sdk/client-kms'; + +async function decryptWithKMS(base64Ciphertext: string): Promise { + try { + // Step 1: Decode from base64 (you stored it this way, remember?) + const ciphertextBlob = Buffer.from(base64Ciphertext, 'base64'); + + // Step 2: Create decrypt command + const command = new DecryptCommand({ + CiphertextBlob: ciphertextBlob, + }); + + // Step 3: Send command + const response = await client.send(command); + + // Step 4: Handle binary response + const plaintext = response.Plaintext; + + // Step 5: Convert binary to string + return Buffer.from(plaintext).toString('utf-8'); + } catch (error) { + console.error('Error decrypting data:', error); + throw error; + } +} +``` + +### Protect.js: One Line + +```typescript +// Decrypt: One call, returns typed value +const decryptResult = await protectClient.decrypt(ciphertext); + +if (decryptResult.failure) { + throw new Error(decryptResult.failure.message); +} + +const plaintext = decryptResult.data; // Already a string, typed correctly +``` + +## Features That AWS KMS Can't Do (Without Major Custom Work) + +### 1. Searchable Encryption: Built-in vs. Impossible + +**AWS KMS:** Searching encrypted data requires either: +- Decrypting everything and searching in memory (not scalable) +- Storing plaintext indexes (defeats the purpose of encryption) +- Building a custom searchable encryption solution (months of work) + +**Protect.js:** Searchable encryption is built-in and works with PostgreSQL: + +```typescript +// Just add search capabilities to your schema +const users = csTable('users', { + email: csColumn('email') + .freeTextSearch() // Full-text search + .equality() // WHERE email = ? + .orderAndRange(), // ORDER BY, range queries +}); + +// Encrypt as usual +const encryptResult = await protectClient.encrypt( + 'secret@squirrel.example', + { column: users.email, table: users } +); + +// Create search terms and query directly in PostgreSQL +const searchTerms = await protectClient.createSearchTerms({ + terms: ['secret'], + column: users.email, + table: users, +}); + +// Use with your ORM (Drizzle integration included) +``` + +**Result:** You can search encrypted data without ever decrypting it. This is impossible with AWS KMS without building a custom solution. + +### 2. Identity-Aware Encryption: Built-in vs. Custom Implementation + +**AWS KMS:** No built-in support. You must: +- Implement custom logic to associate encryption with user identity +- Manage user-specific keys or key aliases manually +- Use encryption context (key-value pairs) as a workaround + +```typescript +// AWS KMS: Custom implementation required +const command = new EncryptCommand({ + KeyId: keyId, + Plaintext: Buffer.from(plaintext), + EncryptionContext: { + 'user-id': userId, // Just metadata, not enforced + 'session-id': sessionId, + }, +}); +// You must manually ensure the same context is used for decryption +``` + +**Protect.js:** Built-in identity-aware encryption with `LockContext`: + +```typescript +import { LockContext } from '@cipherstash/protect/identify'; + +// Create lock context from user JWT (one line) +const lc = new LockContext(); +const lockContext = await lc.identify(userJwt); + +// Encrypt with lock context (chainable API) +const encryptResult = await protectClient.encrypt( + 'secret@squirrel.example', + { column: users.email, table: users } +).withLockContext(lockContext); + +// Decrypt requires the same lock context (enforced by Protect.js) +const decryptResult = await protectClient.decrypt(ciphertext) + .withLockContext(lockContext); +``` + +**Result:** Identity-aware encryption that's enforced by the system, not just metadata you hope developers remember to check. + +### 3. Bulk Operations: Native API vs. Manual Batching + +**AWS KMS:** No bulk API. You must: +- Manually batch operations +- Handle rate limits yourself +- Manage concurrency +- Deal with partial failures + +```typescript +// AWS KMS: Manual batching with rate limit management +const encryptedItems = await Promise.all( + items.map(item => encryptWithKMS(item)) +); +// Hope you don't hit rate limits or need to retry +``` + +**Protect.js:** Native bulk encryption optimized for performance: + +```typescript +// Protect.js: One call for bulk encryption +const bulkPlaintexts = [ + { id: '1', plaintext: 'Alice' }, + { id: '2', plaintext: 'Bob' }, + { id: '3', plaintext: 'Charlie' }, +]; + +const bulkResult = await protectClient.bulkEncrypt(bulkPlaintexts, { + column: users.name, + table: users, +}); + +// Returns map of id -> encrypted value, optimized for performance +const encryptedMap = bulkResult.data; +``` + +## Developer Experience Comparison + +### Error Handling + +**AWS KMS:** Try/catch with manual error type checking: + +```typescript +try { + const response = await client.send(command); +} catch (error) { + // Manually check error types + if (error.name === 'AccessDeniedException') { + // Handle access denied + } else if (error.name === 'InvalidKeyUsageException') { + // Handle invalid key usage + } + // Hope you caught all the error types +} +``` + +**Protect.js:** Type-safe Result pattern: + +```typescript +const result = await protectClient.encrypt(plaintext, options); + +if (result.failure) { + // Type-safe error handling with autocomplete + switch (result.failure.type) { + case 'EncryptionError': + // TypeScript knows this is an EncryptionError + break; + case 'ClientInitError': + // TypeScript knows this is a ClientInitError + break; + } +} +``` + +### Type Safety + +**AWS KMS:** Manual typing, binary data handling: + +```typescript +// You must manually type everything +const plaintext: string = Buffer.from(response.Plaintext).toString('utf-8'); +``` + +**Protect.js:** Full TypeScript support with inferred types: + +```typescript +// TypeScript infers the return type automatically +const plaintext = decryptResult.data; // Type: string +``` + +### Storage Format + +**AWS KMS:** Binary data that needs encoding: + +```typescript +// Returns Uint8Array, must encode for storage +const base64 = Buffer.from(ciphertext).toString('base64'); +// Store in database as TEXT or BLOB +``` + +**Protect.js:** JSON payload ready for database: + +```typescript +// Returns JSON payload ready for JSONB storage +const ciphertext = encryptResult.data; +// Store directly in PostgreSQL JSONB column +// Example: { c: '\\x61202020202020472aaf602219d48c4a...' } +``` + +## Complete Workflow Comparison + +### AWS KMS: Full Implementation + +```typescript +import { KMSClient, EncryptCommand, DecryptCommand } from '@aws-sdk/client-kms'; + +// Configuration +const client = new KMSClient({ region: 'us-west-2' }); +const keyId = 'arn:aws:kms:us-west-2:123456789012:key/abcd1234-efgh-5678-ijkl-9012mnopqrst'; + +// Encrypt function with all manual work +async function encrypt(plaintext: string): Promise { + const command = new EncryptCommand({ + KeyId: keyId, + Plaintext: Buffer.from(plaintext), + }); + const response = await client.send(command); + return Buffer.from(response.CiphertextBlob).toString('base64'); +} + +// Decrypt function with all manual work +async function decrypt(base64Ciphertext: string): Promise { + const command = new DecryptCommand({ + CiphertextBlob: Buffer.from(base64Ciphertext, 'base64'), + }); + const response = await client.send(command); + return Buffer.from(response.Plaintext).toString('utf-8'); +} + +// Usage +const encrypted = await encrypt('secret@squirrel.example'); +const decrypted = await decrypt(encrypted); +``` + +**Lines of code:** ~25 lines for basic encrypt/decrypt +**What you manage:** Key ARNs, binary conversions, base64 encoding, error handling, AWS credentials, regions + +### Protect.js: Full Implementation + +```typescript +import { protect, csTable, csColumn } from '@cipherstash/protect'; + +// One-time schema definition +const users = csTable('users', { + email: csColumn('email'), +}); + +// One-time initialization +const protectClient = await protect({ + schemas: [users], +}); + +// Encrypt +const encryptResult = await protectClient.encrypt( + 'secret@squirrel.example', + { column: users.email, table: users } +); + +if (encryptResult.failure) { + throw new Error(encryptResult.failure.message); +} + +const ciphertext = encryptResult.data; + +// Decrypt +const decryptResult = await protectClient.decrypt(ciphertext); + +if (decryptResult.failure) { + throw new Error(decryptResult.failure.message); +} + +const plaintext = decryptResult.data; +``` + +**Lines of code:** ~20 lines including setup +**What you manage:** Nothing—Protect.js handles it all + +## Feature Comparison + +| Feature | AWS KMS | Protect.js | +|---------|---------|------------| +| **Basic Encryption** | ✅ Requires manual buffer/base64 handling | ✅ One-line API, JSON payload | +| **Key Management** | ❌ You manage key ARNs | ✅ Zero-knowledge, automatic | +| **Searchable Encryption** | ❌ Not possible | ✅ Built-in for PostgreSQL | +| **Identity-Aware Encryption** | ❌ Custom implementation | ✅ Built-in `LockContext` | +| **Bulk Operations** | ❌ Manual batching | ✅ Native bulk API | +| **Error Handling** | ❌ Try/catch, manual types | ✅ Type-safe Result pattern | +| **Type Safety** | ❌ Manual typing | ✅ Full TypeScript inference | +| **Storage Format** | ❌ Binary (needs encoding) | ✅ JSON (database-ready) | +| **ORM Integration** | ❌ Manual integration | ✅ Built-in Drizzle support | +| **Zero-Knowledge** | ❌ AWS has key access | ✅ True zero-knowledge | +| **Setup Complexity** | Medium (AWS credentials, regions) | Low (just environment variables) | + +## The Bottom Line + +**AWS KMS** is a powerful key management service, but using it for application-level encryption requires: +- Writing wrapper functions for every operation +- Managing binary data conversions +- Handling base64 encoding/decoding +- Building custom solutions for searchable encryption +- Implementing identity-aware encryption yourself +- Managing key ARNs and AWS configuration + +**Protect.js** gives you: +- A simple, type-safe API +- Built-in searchable encryption +- Built-in identity-aware encryption +- Zero-knowledge key management +- Database-ready JSON payloads +- ORM integration out of the box + +**Result:** Focus on building your application, not managing encryption infrastructure. + +## When to Use Each + +### Use AWS KMS when: +- You need encryption for AWS services (S3, EBS, etc.) +- You're encrypting infrastructure-level resources +- You don't need to search encrypted data +- You're comfortable with manual buffer/base64 handling + +### Use Protect.js when: +- You're building applications with databases +- You need to search encrypted data +- You want a developer-friendly API +- You need identity-aware encryption +- You want zero-knowledge key management +- You value type safety and developer experience + +--- + +## References + +- [AWS KMS Documentation](https://docs.aws.amazon.com/kms/) +- [CipherStash Protect.js Getting Started](./getting-started.md) +- [CipherStash Schema Reference](./reference/schema.md) +- [Searchable Encryption Concepts](./concepts/searchable-encryption.md) diff --git a/packages/protect/__tests__/backward-compat.test.ts b/packages/protect/__tests__/backward-compat.test.ts index 128ab872..46d39949 100644 --- a/packages/protect/__tests__/backward-compat.test.ts +++ b/packages/protect/__tests__/backward-compat.test.ts @@ -1,6 +1,6 @@ import 'dotenv/config' import { csColumn, csTable } from '@cipherstash/schema' -import { describe, expect, it, beforeAll } from 'vitest' +import { beforeAll, describe, expect, it } from 'vitest' import { protect } from '../src' const users = csTable('users', { From 0d816b4609260a2486598c5cbdf9fb267da25436 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Mon, 19 Jan 2026 15:51:09 -0700 Subject: [PATCH 3/3] feat(stash): add confirmation on deletion --- packages/protect/src/bin/stash.ts | 55 +++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/protect/src/bin/stash.ts b/packages/protect/src/bin/stash.ts index 96106afe..87833527 100644 --- a/packages/protect/src/bin/stash.ts +++ b/packages/protect/src/bin/stash.ts @@ -5,6 +5,7 @@ import { buildRouteMap, run, } from '@stricli/core' +import readline from 'node:readline' import { Stash } from '../stash/index.js' // ANSI color codes for beautiful terminal output @@ -89,6 +90,24 @@ function createStash(environment: string): Stash { return new Stash(config) } +/** + * Prompt user for confirmation + */ +function askConfirmation(prompt: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + return new Promise((resolve) => { + rl.question(prompt, (answer) => { + rl.close() + const normalized = answer.trim().toLowerCase() + resolve(normalized === 'y' || normalized === 'yes') + }) + }) +} + /** * Set command - Store an encrypted secret */ @@ -296,12 +315,28 @@ Examples: * Delete command - Delete a secret from the vault */ const deleteCommand = buildCommand({ - func: async (flags: { name: string; environment: string }) => { - const { name, environment } = flags + func: async (flags: { + name: string + environment: string + yes?: boolean + }) => { + const { name, environment, yes } = flags const stash = createStash(environment) + // Ask for confirmation unless --yes flag is set + if (!yes) { + const confirmation = await askConfirmation( + `${style.warning(`Are you sure you want to delete secret "${name}" from environment "${environment}"? This action cannot be undone. (yes/no): `)}`, + ) + + if (!confirmation) { + console.log(style.info('Deletion cancelled.')) + return + } + } + console.log( - `${style.warning(`Deleting secret "${name}" from environment "${environment}"...`)}`, + `${style.info(`Deleting secret "${name}" from environment "${environment}"...`)}`, ) const result = await stash.delete(name) @@ -330,21 +365,28 @@ const deleteCommand = buildCommand({ parse: String, brief: 'Environment name (e.g., production, staging, development)', }, + yes: { + kind: 'boolean', + optional: true, + brief: 'Skip confirmation prompt', + }, }, aliases: { n: 'name', e: 'environment', + y: 'yes', }, }, docs: { brief: 'Delete a secret from CipherStash', fullDescription: ` Permanently delete a secret from the specified environment. This action cannot be undone. +By default, you will be prompted for confirmation before deletion. Use --yes to skip the confirmation. Examples: stash secrets delete --name DATABASE_URL --environment production - stash secrets delete -n DATABASE_URL -e production - stash secrets delete --name API_KEY --environment staging + stash secrets delete -n DATABASE_URL -e production --yes + stash secrets delete --name API_KEY --environment staging -y `.trim(), }, }) @@ -385,7 +427,8 @@ Examples: stash secrets list --environment production stash secrets list -e production stash secrets delete --name DATABASE_URL --environment production - stash secrets delete -n DATABASE_URL -e production + stash secrets delete -n DATABASE_URL -e production --yes + stash secrets delete -n DATABASE_URL -e production -y `.trim(), }, })