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
5 changes: 5 additions & 0 deletions .changeset/cli-update-secret.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/commands/cloud/secrets/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <secretId>"];
static override examples = [
"browse cloud secrets delete <secretId>",
"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,
}),
};
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/commands/cloud/secrets/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <secretId>"];
static override examples = [
"browse cloud secrets get <secretId>",
"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,
}),
};
Expand Down
36 changes: 36 additions & 0 deletions packages/cli/src/commands/cloud/secrets/update.ts
Original file line number Diff line number Diff line change
@@ -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 <secretId>",
"browse cloud secrets update d2c4f48f-38e9-4b82-a36a-2b373fd14a65",
"browse cloud secrets update <secretId> --env MY_SERVICE_TOKEN",
"browse cloud secrets update <secretId> --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<void> {
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);
}
}
}
31 changes: 24 additions & 7 deletions packages/cli/src/lib/secrets/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,35 @@ export async function createSecret(
secretKey: string,
value: Uint8Array,
): Promise<Secret> {
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<Secret> {
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 };
}
217 changes: 217 additions & 0 deletions packages/cli/tests/cli-secrets-update-contract.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Comment thread
AzamAbdul marked this conversation as resolved.
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");
});
});
1 change: 1 addition & 0 deletions packages/cli/tests/cli-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
Loading