diff --git a/.changeset/cli-create-secret.md b/.changeset/cli-create-secret.md new file mode 100644 index 000000000..104d8afb0 --- /dev/null +++ b/.changeset/cli-create-secret.md @@ -0,0 +1,5 @@ +--- +"browse": minor +--- + +Add `browse cloud secrets create` with public-key lookup, local encryption, and secret input from stdin, a named environment variable, or a hidden prompt. diff --git a/packages/cli/package.json b/packages/cli/package.json index 38456f5b2..ceb830eaa 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,7 +37,7 @@ "description": "Manage Browserbase cloud resources and APIs." }, "cloud:secrets": { - "description": "List, retrieve, and delete project secrets." + "description": "Create, list, retrieve, and delete project secrets." }, "cloud:projects": { "description": "Manage Browserbase projects." @@ -107,6 +107,9 @@ "dependencies": { "@browserbasehq/sdk": "^2.17.0", "@browserbasehq/stagehand": "workspace:*", + "@hpke/core": "^1.9.0", + "@hpke/dhkem-x25519": "^1.8.0", + "@inquirer/password": "^4.0.23", "@oclif/core": "^4.11.0", "@vercel/detect-agent": "^1.2.3", "archiver": "^7.0.1", diff --git a/packages/cli/src/commands/cloud/secrets/create.ts b/packages/cli/src/commands/cloud/secrets/create.ts new file mode 100644 index 000000000..9fc3291f2 --- /dev/null +++ b/packages/cli/src/commands/cloud/secrets/create.ts @@ -0,0 +1,34 @@ +import { Args } from "@oclif/core"; +import { BrowseCommand } from "../../../base.js"; +import { apiCommonFlags, toApiOptions } from "../../../lib/cloud/flags.js"; +import { createSecret } from "../../../lib/secrets/api.js"; +import { readSecretValue } from "../../../lib/secrets/input.js"; +import { secretInputFlags } from "../../../lib/secrets/flags.js"; +import { outputJson } from "../../../lib/output.js"; + +export default class SecretsCreate extends BrowseCommand { + static override description = + "Create a project secret. Encrypts the value locally with the project public key."; + static override examples = [ + "browse cloud secrets create SERVICE_TOKEN", + "browse cloud secrets create SERVICE_TOKEN --env MY_SERVICE_TOKEN", + "browse cloud secrets create SERVICE_TOKEN --stdin < ./secret.txt", + ]; + static override args = { + key: Args.string({ + description: "Name exposed in the function context.secrets object.", + required: true, + }), + }; + static override flags = { ...apiCommonFlags, ...secretInputFlags }; + async run(): Promise { + const { args, flags } = await this.parse(SecretsCreate); + const options = toApiOptions(flags); + const value = await readSecretValue({ stdin: flags.stdin, env: flags.env }); + try { + outputJson(await createSecret(options, args.key, value)); + } finally { + value.fill(0); + } + } +} diff --git a/packages/cli/src/lib/secrets/api.ts b/packages/cli/src/lib/secrets/api.ts index 429df82f3..a8cb8e7c5 100644 --- a/packages/cli/src/lib/secrets/api.ts +++ b/packages/cli/src/lib/secrets/api.ts @@ -1,3 +1,4 @@ +import { sealSecret } from "./seal.js"; import { requestBrowserbase, requestBrowserbaseJson } from "../cloud/api.js"; export interface SecretsApiOptions { @@ -56,3 +57,24 @@ export async function deleteSecret( function secretPath(id: string): string { return `/v1/secrets/${encodeURIComponent(id)}`; } + +export async function createSecret( + options: SecretsApiOptions, + secretKey: string, + value: Uint8Array, +): Promise { + const keypair = await requestBrowserbaseJson<{ + id: string; + publicKey: string; + }>(options, "/v1/secrets/keypair"); + const sealedSecretValue = await sealSecret(keypair.publicKey, value); + return requestBrowserbaseJson(options, "/v1/secrets", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + secretKey, + keypairId: keypair.id, + sealedSecretValue, + }), + }); +} diff --git a/packages/cli/src/lib/secrets/flags.ts b/packages/cli/src/lib/secrets/flags.ts index ea44c6c20..505030b59 100644 --- a/packages/cli/src/lib/secrets/flags.ts +++ b/packages/cli/src/lib/secrets/flags.ts @@ -32,3 +32,16 @@ export function toListSecretsOptions(flags: { endAt: flags["end-at"], }; } + +export const secretInputFlags = { + env: Flags.string({ + description: "Read the secret value from the named environment variable.", + helpValue: "VARIABLE_NAME", + exclusive: ["stdin"], + }), + stdin: Flags.boolean({ + description: + "Read the exact secret value from stdin, preserving whitespace.", + exclusive: ["env"], + }), +}; diff --git a/packages/cli/src/lib/secrets/input.ts b/packages/cli/src/lib/secrets/input.ts new file mode 100644 index 000000000..3acc59bb0 --- /dev/null +++ b/packages/cli/src/lib/secrets/input.ts @@ -0,0 +1,39 @@ +import password from "@inquirer/password"; +import { fail } from "../errors.js"; + +export async function readSecretValue(options: { + stdin?: boolean; + env?: string; +}): Promise { + if (options.env !== undefined) { + if (options.stdin) fail("--env and --stdin cannot be used together."); + if (!options.env) fail("--env requires an environment variable name."); + const value = Object.prototype.hasOwnProperty.call(process.env, options.env) + ? process.env[options.env] + : undefined; + if (value === undefined) + fail("The environment variable selected by --env is not set."); + return Buffer.from(value, "utf8"); + } + if (options.stdin) { + if (process.stdin.isTTY) + fail("--stdin requires piped input or file redirection."); + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); + } + if (!process.stdin.isTTY) + fail( + "Use --stdin for piped input or --env to read an environment variable.", + ); + try { + return Buffer.from( + await password({ message: "Secret value:" }, { output: process.stderr }), + "utf8", + ); + } catch { + fail("Secret input cancelled."); + } +} diff --git a/packages/cli/src/lib/secrets/seal.ts b/packages/cli/src/lib/secrets/seal.ts new file mode 100644 index 000000000..3340134af --- /dev/null +++ b/packages/cli/src/lib/secrets/seal.ts @@ -0,0 +1,35 @@ +import { Aes256Gcm, CipherSuite, HkdfSha256 } from "@hpke/core"; +import { DhkemX25519HkdfSha256 } from "@hpke/dhkem-x25519"; +import { fail } from "../errors.js"; + +export async function sealSecret( + publicKey: unknown, + value: Uint8Array, +): Promise { + if (typeof publicKey !== "string") { + fail("The secrets API returned an invalid X25519 public key."); + } + const rawKey = Buffer.from(publicKey, "base64"); + if (rawKey.length !== 32 || rawKey.toString("base64") !== publicKey) { + fail("The secrets API returned an invalid X25519 public key."); + } + const suite = new CipherSuite({ + kem: new DhkemX25519HkdfSha256(), + kdf: new HkdfSha256(), + aead: new Aes256Gcm(), + }); + try { + const recipientPublicKey = await suite.kem.deserializePublicKey( + new Uint8Array(rawKey).buffer, + ); + const sender = await suite.createSenderContext({ recipientPublicKey }); + const ciphertext = await sender.seal(new Uint8Array(value).buffer); + // Go's crypto/hpke.Open expects the encapsulated key followed by ciphertext. + return Buffer.concat([ + Buffer.from(sender.enc), + Buffer.from(ciphertext), + ]).toString("base64"); + } catch { + fail("Failed to encrypt the secret with the project's public key."); + } +} diff --git a/packages/cli/tests/cli-secrets-create-contract.test.ts b/packages/cli/tests/cli-secrets-create-contract.test.ts new file mode 100644 index 000000000..474b5882a --- /dev/null +++ b/packages/cli/tests/cli-secrets-create-contract.test.ts @@ -0,0 +1,220 @@ +import { Aes256Gcm, CipherSuite, HkdfSha256 } from "@hpke/core"; +import { DhkemX25519HkdfSha256 } from "@hpke/dhkem-x25519"; +import { afterEach, describe, expect, it } from "vitest"; +import { + jsonResponse, + startFakeBrowserbaseServer, + type FakeBrowserbaseServer, +} from "./helpers/fake-browserbase-server.js"; +import { runCli } from "./helpers/run-cli.js"; + +const secretId = "d2c4f48f-38e9-4b82-a36a-2b373fd14a65"; +const metadata = { id: secretId, secretKey: "SERVICE_TOKEN" }; +const env = { + BROWSERBASE_API_KEY: "test-key", + BROWSE_LOAD_DOTENV: "0", + BROWSERBASE_TELEMETRY_DISABLED: "1", +}; +let server: FakeBrowserbaseServer | undefined; +afterEach(async () => { + await server?.close(); + server = undefined; +}); + +function suite() { + return new CipherSuite({ + kem: new DhkemX25519HkdfSha256(), + kdf: new HkdfSha256(), + aead: new Aes256Gcm(), + }); +} + +async function keypair() { + const crypto = suite(); + const pair = await crypto.kem.generateKeyPair(); + const publicKey = Buffer.from( + await crypto.kem.serializePublicKey(pair.publicKey), + ).toString("base64"); + return { crypto, pair, publicKey }; +} + +describe("secret CLI HTTP contracts", () => { + it.each(["stdin", "env"])( + "fetches the public key and encrypts exact %s bytes", + async (source) => { + const { crypto, pair, publicKey } = await keypair(); + const value = " token-🔑\nwith-whitespace\r\n"; + server = await startFakeBrowserbaseServer((request, response) => { + if (request.path === "/v1/secrets/keypair") + jsonResponse(response, 200, { id: "keypair-1", publicKey }); + else jsonResponse(response, 201, metadata); + }); + const result = await runCli( + [ + "cloud", + "secrets", + "create", + "SERVICE_TOKEN", + ...(source === "stdin" + ? ["--stdin"] + : ["--env", "BROWSE_TEST_SECRET_VALUE"]), + "--base-url", + server.baseUrl, + ], + source === "stdin" + ? { env, stdin: value } + : { env: { ...env, BROWSE_TEST_SECRET_VALUE: value } }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(metadata); + expect(server.requests.map((r) => [r.method, r.path])).toEqual([ + ["GET", "/v1/secrets/keypair"], + ["POST", "/v1/secrets"], + ]); + for (const request of server.requests) + expect(request.headers["x-bb-api-key"]).toBe("test-key"); + const body = server.requests[1]!.jsonBody as { + keypairId: string; + sealedSecretValue: string; + secretKey?: string; + }; + expect(body.keypairId).toBe("keypair-1"); + expect(Object.keys(body).sort()).toEqual( + ["secretKey", "sealedSecretValue", "keypairId"].sort(), + ); + expect(body.secretKey).toBe("SERVICE_TOKEN"); + expect(server.requests[1]!.headers["content-type"]).toBe( + "application/json", + ); + const blob = Buffer.from(body.sealedSecretValue, "base64"); + const recipient = await crypto.createRecipientContext({ + recipientKey: pair.privateKey, + enc: new Uint8Array(blob.subarray(0, 32)).buffer, + }); + expect( + Buffer.from( + await recipient.open(new Uint8Array(blob.subarray(32)).buffer), + ).toString("utf8"), + ).toBe(value); + expect(server.requests[1]!.bodyText).not.toContain("token-🔑"); + expect(result.stdout + result.stderr).not.toContain("token-🔑"); + }, + ); + + it("stops if key retrieval fails", async () => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 403, { message: "Forbidden" }), + ); + const result = await runCli( + [ + "cloud", + "secrets", + "create", + "TOKEN", + "--stdin", + "--base-url", + server.baseUrl, + ], + { env, stdin: "private-value" }, + ); + expect(result.exitCode).not.toBe(0); + expect(server.requests).toHaveLength(1); + expect(result.stderr).toContain("Forbidden"); + expect(result.stdout + result.stderr).not.toContain("private-value"); + }); + + it.each(["bad", undefined, null, 123, true, {}, []].map((key) => [key]))( + "rejects malformed public key %j without submitting a secret", + async (publicKey) => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 200, { id: "keypair-1", publicKey }), + ); + const result = await runCli( + [ + "cloud", + "secrets", + "create", + "TOKEN", + "--stdin", + "--base-url", + server.baseUrl, + ], + { env, stdin: "private-value" }, + ); + expect(result.exitCode).not.toBe(0); + expect(server.requests).toHaveLength(1); + expect(result.stderr).toContain("invalid X25519 public key"); + expect(result.stdout + result.stderr).not.toContain("private-value"); + }, + ); + + it("requires --stdin for noninteractive input", async () => { + const result = await runCli(["cloud", "secrets", "create", "TOKEN"], { + env, + stdin: "private-value", + }); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Use --stdin"); + expect(result.stdout + result.stderr).not.toContain("private-value"); + }); + + it("reports a duplicate key without retrying creation", async () => { + const { publicKey } = await keypair(); + server = await startFakeBrowserbaseServer((request, response) => { + if (request.path === "/v1/secrets/keypair") { + jsonResponse(response, 200, { id: "keypair-1", publicKey }); + } else { + jsonResponse(response, 409, { message: "Secret already exists" }); + } + }); + const result = await runCli( + [ + "cloud", + "secrets", + "create", + "TOKEN", + "--stdin", + "--base-url", + server.baseUrl, + ], + { env, stdin: "private-value" }, + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Secret already exists"); + expect(server.requests).toHaveLength(2); + expect(result.stdout + result.stderr).not.toContain("private-value"); + }); +}); + +describe("environment secret input validation", () => { + it.each([ + ["--env", "BROWSE_TEST_MISSING_SECRET_VALUE"], + ["--env", ""], + ["--env", "BROWSE_TEST_SECRET_VALUE", "--stdin"], + ])("rejects invalid input flags %j before requesting", async (...flags) => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 200, {}), + ); + const result = await runCli( + [ + "cloud", + "secrets", + "create", + "TOKEN", + "--base-url", + server.baseUrl, + ...flags, + ], + { + env: { + ...env, + BROWSE_TEST_MISSING_SECRET_VALUE: undefined, + BROWSE_TEST_SECRET_VALUE: "private-value", + }, + }, + ); + expect(result.exitCode).not.toBe(0); + expect(server.requests).toHaveLength(0); + expect(result.stdout + result.stderr).not.toContain("private-value"); + }); +}); diff --git a/packages/cli/tests/cli-surface.test.ts b/packages/cli/tests/cli-surface.test.ts index 4cf2a3c8b..b22bf1135 100644 --- a/packages/cli/tests/cli-surface.test.ts +++ b/packages/cli/tests/cli-surface.test.ts @@ -4,6 +4,7 @@ import { runCli } from "./helpers/run-cli.js"; const cloudCommandsWithExamples = [ ["cloud", "secrets", "list"], + ["cloud", "secrets", "create"], ["cloud", "secrets", "get"], ["cloud", "secrets", "delete"], ["cloud", "projects", "list"], diff --git a/packages/cli/tests/helpers/run-cli.ts b/packages/cli/tests/helpers/run-cli.ts index 8a9fafa9d..e13ac73bc 100644 --- a/packages/cli/tests/helpers/run-cli.ts +++ b/packages/cli/tests/helpers/run-cli.ts @@ -12,6 +12,7 @@ export interface CliResult { export interface RunCliOptions { cwd?: string; + stdin?: string; env?: NodeJS.ProcessEnv; } @@ -31,16 +32,25 @@ export function runCli( NODE_ENV: "test", ...options.env, }, - stdio: ["ignore", "pipe", "pipe"], + stdio: [ + options.stdin === undefined ? "ignore" : "pipe", + "pipe", + "pipe", + ], }, ); + if (options.stdin !== undefined) { + child.stdin?.on("error", () => {}); + child.stdin?.end(options.stdin); + } + let stdout = ""; let stderr = ""; - child.stdout.on("data", (chunk) => { + child.stdout!.on("data", (chunk) => { stdout += chunk.toString(); }); - child.stderr.on("data", (chunk) => { + child.stderr!.on("data", (chunk) => { stderr += chunk.toString(); }); child.on("error", reject); diff --git a/packages/cli/tests/secrets-input.test.ts b/packages/cli/tests/secrets-input.test.ts new file mode 100644 index 000000000..586432b36 --- /dev/null +++ b/packages/cli/tests/secrets-input.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import password from "@inquirer/password"; +import { readSecretValue } from "../src/lib/secrets/input.js"; + +vi.mock("@inquirer/password", () => ({ default: vi.fn() })); + +const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); +beforeEach(() => { + vi.mocked(password).mockReset(); + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: true, + }); +}); +afterEach(() => { + vi.unstubAllEnvs(); + if (originalIsTTY) + Object.defineProperty(process.stdin, "isTTY", originalIsTTY); + else Reflect.deleteProperty(process.stdin, "isTTY"); +}); + +describe("interactive secret input", () => { + it("uses a hidden prompt on stderr and preserves whitespace", async () => { + vi.mocked(password).mockResolvedValue(" secret value "); + expect(Buffer.from(await readSecretValue({})).toString()).toBe( + " secret value ", + ); + expect(password).toHaveBeenCalledWith( + { message: "Secret value:" }, + { output: process.stderr }, + ); + }); + + it("reports cancellation without echoing the prompt error", async () => { + vi.mocked(password).mockRejectedValue(new Error("private-value")); + await expect(readSecretValue({})).rejects.toMatchObject({ + message: "Secret input cancelled.", + }); + }); +}); + +describe("environment secret input", () => { + it.each(["constructor", "toString"])( + "rejects an unset inherited environment property %s", + async (env) => { + vi.stubEnv(env, undefined); + await expect(readSecretValue({ env })).rejects.toMatchObject({ + name: "CommandFailure", + message: "The environment variable selected by --env is not set.", + }); + expect(password).not.toHaveBeenCalled(); + }, + ); + + it.each(["constructor", "toString"])( + "reads an explicitly set environment property %s", + async (env) => { + vi.stubEnv(env, "private-value"); + expect(Buffer.from(await readSecretValue({ env })).toString()).toBe( + "private-value", + ); + }, + ); + + it("preserves an explicitly empty value without prompting", async () => { + vi.stubEnv("BROWSE_TEST_SECRET_VALUE", ""); + expect( + await readSecretValue({ env: "BROWSE_TEST_SECRET_VALUE" }), + ).toHaveLength(0); + expect(password).not.toHaveBeenCalled(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a47fb9db1..1f42e8d2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -441,6 +441,15 @@ importers: '@browserbasehq/stagehand': specifier: workspace:* version: link:../sdk-ts + '@hpke/core': + specifier: ^1.9.0 + version: 1.9.0 + '@hpke/dhkem-x25519': + specifier: ^1.8.0 + version: 1.8.0 + '@inquirer/password': + specifier: ^4.0.23 + version: 4.0.23(@types/node@20.19.43) '@oclif/core': specifier: ^4.11.0 version: 4.13.0 @@ -2183,6 +2192,18 @@ packages: peerDependencies: hono: ^4 + '@hpke/common@1.10.1': + resolution: {integrity: sha512-moJwhmtLtuxiUzzNp1jpfBfx8yefKoO9D/RCR9dmwrnc7qjJqId1rEtQz+lSlU5cabX8daToMSx/7HayXOiaFw==} + engines: {node: '>=16.0.0'} + + '@hpke/core@1.9.0': + resolution: {integrity: sha512-pFxWl1nNJeQCSUFs7+GAblHvXBCjn9EPN65vdKlYQil2aURaRxfGMO6vBKGqm1YHTKwiAxJQNEI70PbSowMP9Q==} + engines: {node: '>=16.0.0'} + + '@hpke/dhkem-x25519@1.8.0': + resolution: {integrity: sha512-S1MWWkAfu+TFxySgv5+2P3O4Mx/jk7BsoplzQaA1s3sfUJVJ2UsZsSzSsMc+FXJumLXncoJFlO6mK6mDGspfmA==} + engines: {node: '>=16.0.0'} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -10421,6 +10442,16 @@ snapshots: dependencies: hono: 4.12.31 + '@hpke/common@1.10.1': {} + + '@hpke/core@1.9.0': + dependencies: + '@hpke/common': 1.10.1 + + '@hpke/dhkem-x25519@1.8.0': + dependencies: + '@hpke/common': 1.10.1 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0