diff --git a/.changeset/cli-update-secret.md b/.changeset/cli-update-secret.md new file mode 100644 index 000000000..091ad39f7 --- /dev/null +++ b/.changeset/cli-update-secret.md @@ -0,0 +1,5 @@ +--- +"browse": minor +--- + +Add `browse cloud secrets update` to replace a secret value by ID with local encryption and stdin, environment variable, or hidden prompt input. diff --git a/packages/cli/package.json b/packages/cli/package.json index ceb830eaa..5fdf12028 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": "Create, list, retrieve, and delete project secrets." + "description": "Create, list, retrieve, update, and delete project secrets." }, "cloud:projects": { "description": "Manage Browserbase projects." diff --git a/packages/cli/src/commands/cloud/secrets/delete.ts b/packages/cli/src/commands/cloud/secrets/delete.ts index 8fe7e8aa7..cb8caf592 100644 --- a/packages/cli/src/commands/cloud/secrets/delete.ts +++ b/packages/cli/src/commands/cloud/secrets/delete.ts @@ -5,10 +5,14 @@ import { deleteSecret } from "../../../lib/secrets/api.js"; export default class SecretsDelete extends BrowseCommand { static override description = "Delete a project secret."; - static override examples = ["browse cloud secrets delete "]; + static override examples = [ + "browse cloud secrets delete ", + "browse cloud secrets delete d2c4f48f-38e9-4b82-a36a-2b373fd14a65", + ]; static override args = { secretId: Args.string({ - description: "Project secret ID.", + description: + "Project secret ID (e.g. d2c4f48f-38e9-4b82-a36a-2b373fd14a65).", required: true, }), }; diff --git a/packages/cli/src/commands/cloud/secrets/get.ts b/packages/cli/src/commands/cloud/secrets/get.ts index 482153f6d..c3d09c3d3 100644 --- a/packages/cli/src/commands/cloud/secrets/get.ts +++ b/packages/cli/src/commands/cloud/secrets/get.ts @@ -7,10 +7,14 @@ import { outputJson } from "../../../lib/output.js"; export default class SecretsGet extends BrowseCommand { static override description = "Get project secret metadata. Does not return the secret value."; - static override examples = ["browse cloud secrets get "]; + static override examples = [ + "browse cloud secrets get ", + "browse cloud secrets get d2c4f48f-38e9-4b82-a36a-2b373fd14a65", + ]; static override args = { secretId: Args.string({ - description: "Project secret ID.", + description: + "Project secret ID (e.g. d2c4f48f-38e9-4b82-a36a-2b373fd14a65).", required: true, }), }; diff --git a/packages/cli/src/commands/cloud/secrets/update.ts b/packages/cli/src/commands/cloud/secrets/update.ts new file mode 100644 index 000000000..3a261a8a1 --- /dev/null +++ b/packages/cli/src/commands/cloud/secrets/update.ts @@ -0,0 +1,36 @@ +import { Args } from "@oclif/core"; +import { BrowseCommand } from "../../../base.js"; +import { apiCommonFlags, toApiOptions } from "../../../lib/cloud/flags.js"; +import { updateSecret } 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 SecretsUpdate extends BrowseCommand { + static override description = + "Replace a secret value, encrypting it locally with the current project public key."; + static override examples = [ + "browse cloud secrets update ", + "browse cloud secrets update d2c4f48f-38e9-4b82-a36a-2b373fd14a65", + "browse cloud secrets update --env MY_SERVICE_TOKEN", + "browse cloud secrets update --stdin < ./secret.txt", + ]; + static override args = { + secretId: Args.string({ + description: + "Project secret ID (e.g. d2c4f48f-38e9-4b82-a36a-2b373fd14a65).", + required: true, + }), + }; + static override flags = { ...apiCommonFlags, ...secretInputFlags }; + async run(): Promise { + const { args, flags } = await this.parse(SecretsUpdate); + const options = toApiOptions(flags); + const value = await readSecretValue({ stdin: flags.stdin, env: flags.env }); + try { + outputJson(await updateSecret(options, args.secretId, value)); + } finally { + value.fill(0); + } + } +} diff --git a/packages/cli/src/lib/secrets/api.ts b/packages/cli/src/lib/secrets/api.ts index a8cb8e7c5..796e591c7 100644 --- a/packages/cli/src/lib/secrets/api.ts +++ b/packages/cli/src/lib/secrets/api.ts @@ -63,18 +63,35 @@ export async function createSecret( secretKey: string, value: Uint8Array, ): Promise { - const keypair = await requestBrowserbaseJson<{ - id: string; - publicKey: string; - }>(options, "/v1/secrets/keypair"); - const sealedSecretValue = await sealSecret(keypair.publicKey, value); + const sealed = await encryptValue(options, value); return requestBrowserbaseJson(options, "/v1/secrets", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ secretKey, - keypairId: keypair.id, - sealedSecretValue, + ...sealed, }), }); } + +export async function updateSecret( + options: SecretsApiOptions, + secretId: string, + value: Uint8Array, +): Promise { + const sealed = await encryptValue(options, value); + return requestBrowserbaseJson(options, secretPath(secretId), { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(sealed), + }); +} + +async function encryptValue(options: SecretsApiOptions, value: Uint8Array) { + const keypair = await requestBrowserbaseJson<{ + id: string; + publicKey: string; + }>(options, "/v1/secrets/keypair"); + const sealedSecretValue = await sealSecret(keypair.publicKey, value); + return { keypairId: keypair.id, sealedSecretValue }; +} diff --git a/packages/cli/tests/cli-secrets-update-contract.test.ts b/packages/cli/tests/cli-secrets-update-contract.test.ts new file mode 100644 index 000000000..16cb14cbb --- /dev/null +++ b/packages/cli/tests/cli-secrets-update-contract.test.ts @@ -0,0 +1,217 @@ +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 update 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, 200, metadata); + }); + const result = await runCli( + [ + "cloud", + "secrets", + "update", + secretId, + ...(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"], + ["PATCH", `/v1/secrets/${secretId}`], + ]); + 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( + ["sealedSecretValue", "keypairId"].sort(), + ); + expect(body.secretKey).toBeUndefined(); + 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", + "update", + "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("rejects a malformed public key without submitting a secret", async () => { + server = await startFakeBrowserbaseServer((_request, response) => + jsonResponse(response, 200, { id: "keypair-1", publicKey: "bad" }), + ); + const result = await runCli( + [ + "cloud", + "secrets", + "update", + "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", "update", "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 missing secret without retrying the update", 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, 404, { message: "Secret not found" }); + } + }); + const result = await runCli( + [ + "cloud", + "secrets", + "update", + "TOKEN", + "--stdin", + "--base-url", + server.baseUrl, + ], + { env, stdin: "private-value" }, + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Secret not found"); + 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", + "update", + "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 b22bf1135..acb731b25 100644 --- a/packages/cli/tests/cli-surface.test.ts +++ b/packages/cli/tests/cli-surface.test.ts @@ -5,6 +5,7 @@ import { runCli } from "./helpers/run-cli.js"; const cloudCommandsWithExamples = [ ["cloud", "secrets", "list"], ["cloud", "secrets", "create"], + ["cloud", "secrets", "update"], ["cloud", "secrets", "get"], ["cloud", "secrets", "delete"], ["cloud", "projects", "list"],