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-create-secret.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 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": "List, retrieve, and delete project secrets."
"description": "Create, list, retrieve, and delete project secrets."
},
"cloud:projects": {
"description": "Manage Browserbase projects."
Expand Down Expand Up @@ -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",
Expand Down
34 changes: 34 additions & 0 deletions packages/cli/src/commands/cloud/secrets/create.ts
Original file line number Diff line number Diff line change
@@ -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",
Comment thread
shrey150 marked this conversation as resolved.
"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<void> {
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);
}
}
}
22 changes: 22 additions & 0 deletions packages/cli/src/lib/secrets/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { sealSecret } from "./seal.js";
import { requestBrowserbase, requestBrowserbaseJson } from "../cloud/api.js";

export interface SecretsApiOptions {
Expand Down Expand Up @@ -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<Secret> {
const keypair = await requestBrowserbaseJson<{
Comment thread
AzamAbdul marked this conversation as resolved.
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,
}),
});
}
13 changes: 13 additions & 0 deletions packages/cli/src/lib/secrets/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
}),
};
39 changes: 39 additions & 0 deletions packages/cli/src/lib/secrets/input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import password from "@inquirer/password";
import { fail } from "../errors.js";

export async function readSecretValue(options: {
stdin?: boolean;
env?: string;
}): Promise<Uint8Array> {
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.");
}
}
35 changes: 35 additions & 0 deletions packages/cli/src/lib/secrets/seal.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
if (typeof publicKey !== "string") {
fail("The secrets API returned an invalid X25519 public key.");
}
const rawKey = Buffer.from(publicKey, "base64");
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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.");
}
}
Loading
Loading