From 766770a71ec65395b8e6133e584cd728d16d9814 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Thu, 22 Oct 2026 09:57:29 +0100 Subject: [PATCH 1/5] ci: skip promotion-pin on release-please PRs (#249) Release-please's release PR targets production but is not a promotion, so the Release-As pin check always failed on it (#230, #247). Co-authored-by: Claude Opus 5.5 --- .github/workflows/cli-ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index 3ec9ff9..fddf327 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -113,8 +113,11 @@ jobs: # # It has to be on a NORMAL commit, not the merge commit — release-please's # commit splitting is unreliable on merges. + # + # Release-please's own release PR also targets `production` but is not a + # promotion: it never carries a pin, so the check would always fail on it. promotion-pin: - if: github.event_name == 'pull_request' && github.base_ref == 'production' + if: github.event_name == 'pull_request' && github.base_ref == 'production' && !startsWith(github.head_ref, 'release-please--') runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 From 55a0609abbc686021fe969c523bcaa1efa994658 Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:46:35 +0100 Subject: [PATCH 2/5] =?UTF-8?q?feat(encrypt):=20list=20--encrypt=20in=20--?= =?UTF-8?q?help=20now=20encryption=20is=20on=20by=20defau=E2=80=A6=20(#250?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(encrypt): list --encrypt in --help now encryption is on by default (dcd#1138) The API now sends encryption.defaultOn = true for every org, not only orgs in the client_encryption beta, so the beta-only pieces come out: - --encrypt is listed in `dcd cloud --help` and `dcd upload --help` again, and its description says it is on by default and that --no-encrypt or DCD_ENCRYPT=0 turns it off. The hidden-arg filter (src/utils/help.ts), which only ever hid this flag, is removed; flags go back to citty's ArgsDef. - The notice drops "(beta)": "Encrypting uploads; opt out with --no-encrypt" (and "encrypt: false" from the MCP tool). It still prints only when the server default turned encryption on, and --json still suppresses it. - The KEK error messages, the MCP encrypt input and comments say "the default" or "the server default" instead of "the organization default". - server.json lists DCD_ENCRYPT, so MCP users can find the opt-out. - environments.ts no longer says the KEK is "left null until generated"; it describes how to rotate. resolveEncryption is unchanged: flag, then DCD_ENCRYPT, then the server default, then off. The default stays server-side so the API's CLIENT_ENCRYPTION_DEFAULT=off kill switch keeps working for every CLI. src/types/generated/schema.types.ts still carries the beta wording in one JSDoc description; a full regeneration would pull in unrelated API surface, so it waits for the next one. --- server.json | 5 +++ src/commands/cloud.ts | 4 +-- src/commands/upload.ts | 4 +-- src/config/environments.ts | 11 ++++--- src/config/flags/binary.flags.ts | 9 ++---- src/constants.ts | 5 +-- src/index.ts | 11 ++----- src/mcp/tools/run-cloud-test.ts | 6 ++-- src/methods.ts | 2 +- src/services/test-submission.service.ts | 2 +- src/utils/compatibility.ts | 5 ++- src/utils/envelope.ts | 4 +-- src/utils/help.ts | 22 ------------- ...dden-help.test.ts => encrypt-help.test.ts} | 31 ++++++------------- test/unit/encryption-decision.test.ts | 2 +- test/unit/upload-encrypt.test.ts | 28 ++++++++--------- 16 files changed, 57 insertions(+), 94 deletions(-) delete mode 100644 src/utils/help.ts rename test/unit/{hidden-help.test.ts => encrypt-help.test.ts} (63%) diff --git a/server.json b/server.json index 5764b41..9145abc 100644 --- a/server.json +++ b/server.json @@ -48,6 +48,11 @@ "name": "DCD_API_URL", "description": "Override the API base URL (e.g. for a non-production environment).", "isRequired": false + }, + { + "name": "DCD_ENCRYPT", + "description": "Uploads (app binary, flows, env vars) are encrypted on your machine by default. Set to 0 to upload them unencrypted; a run's encrypt input overrides this.", + "isRequired": false } ] } diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index 1420b78..92fe2fb 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -199,7 +199,7 @@ export const cloudCommand = defineCommand({ // Client-side envelope encryption of every sensitive artifact: the // binary (#1138), the flow zip (#1151), and env vars (#1152). Kept as // given (--no-encrypt is an explicit false) and resolved once the - // compatibility data, which carries the org's default, is in. + // compatibility data, which carries the server default, is in. const encryptFlag = args.encrypt as boolean | undefined; const includeTags = coerceArray( collectRepeatedFlag(rawArgs, ['--include-tags']), @@ -797,7 +797,7 @@ export const cloudCommand = defineCommand({ } if (encrypt && encryption.source === 'server') { - out(ui.note('Encrypting uploads (beta); opt out with --no-encrypt')); + out(ui.note('Encrypting uploads; opt out with --no-encrypt')); } if (!finalBinaryId) { diff --git a/src/commands/upload.ts b/src/commands/upload.ts index c8c4955..792e49c 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -48,7 +48,7 @@ export const uploadCommand = defineCommand({ const appUrl = args['app-url'] as string | undefined; const ignoreShaCheck = Boolean(args['ignore-sha-check']); // Kept as given, so --no-encrypt is an explicit false and a missing flag - // leaves DCD_ENCRYPT and then the org's default to decide. + // leaves DCD_ENCRYPT and then the server default to decide. const encryptFlag = args.encrypt as boolean | undefined; const debug = Boolean(args.debug); const positional = args.appFile as string | undefined; @@ -121,7 +121,7 @@ export const uploadCommand = defineCommand({ ui.branch([ ...ui.fields([['file', colors.highlight(resolvedFile)]]), ...(encryption.enabled && encryption.source === 'server' - ? [ui.note('Encrypting uploads (beta); opt out with --no-encrypt')] + ? [ui.note('Encrypting uploads; opt out with --no-encrypt')] : []), ]), ); diff --git a/src/config/environments.ts b/src/config/environments.ts index 477ce53..dfdc2db 100644 --- a/src/config/environments.ts +++ b/src/config/environments.ts @@ -22,12 +22,13 @@ export interface DcdEnvironment { * KEK the platform API unwraps with. **Public — safe to embed**: it can only * *encrypt*; the private half lives solely on the API. * - * Left `null` until the KEK is generated and provisioned. Generate a keypair - * with, e.g.: + * Both environments pin v1. To rotate, generate a keypair with, e.g.: * node -e 'const c=require("crypto");const{publicKey,privateKey}=c.generateKeyPairSync("x25519");const pub=publicKey.export({type:"spki",format:"der"}).subarray(12);const priv=privateKey.export({type:"pkcs8",format:"der"}).subarray(16);console.log("public :",pub.toString("base64"));console.log("private:",priv.toString("base64"))' - * Pin `public` here; set `private` as `BINARY_KEK_PRIVATE_KEYS={"":""}` - * on the API service and keep one offline escrow copy. Until then, encryption - * can be exercised via the `DCD_BINARY_KEK_PUBLIC` env override (see envelope.ts). + * Add `private` to the API's `BINARY_KEK_PRIVATE_KEYS` JSON map next to the + * versions already there (dropping one bricks its uploads), keep one offline + * escrow copy, add `public` to dcd's `PINNED_KEK_PUBLIC_KEYS`, then pin it + * here with the new version. `null` pins nothing, so encryption then needs + * the `DCD_BINARY_KEK_PUBLIC` env override (see envelope.ts). */ kekPublicKey: { version: number; key: string } | null; } diff --git a/src/config/flags/binary.flags.ts b/src/config/flags/binary.flags.ts index cbda3f8..96111f3 100644 --- a/src/config/flags/binary.flags.ts +++ b/src/config/flags/binary.flags.ts @@ -1,4 +1,4 @@ -import type { CliArgsDef } from '../../utils/help.js'; +import type { ArgsDef } from 'citty'; /** * Binary upload and management flags @@ -26,10 +26,7 @@ export const binaryFlags = { }, encrypt: { type: 'boolean', - // Left out of --help while encryption is a per-org beta (dcd#1138); the - // flag still parses, --no-encrypt included. - hidden: true, description: - 'Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). The binary is still deduplicated across runs, so an unchanged app is not re-uploaded. Also turned on by DCD_ENCRYPT=1, and by default for orgs in the encryption beta; --no-encrypt or DCD_ENCRYPT=0 turns it off.', + 'Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). On by default; --no-encrypt or DCD_ENCRYPT=0 turns it off. The binary is still deduplicated across runs, so an unchanged app is not re-uploaded.', }, -} as const satisfies CliArgsDef; +} as const satisfies ArgsDef; diff --git a/src/constants.ts b/src/constants.ts index 0030737..7cdf1e1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,6 +3,8 @@ * Flags are organized by domain in src/config/flags/ */ +import type { ArgsDef } from 'citty'; + import { apiFlags } from './config/flags/api.flags.js'; import { binaryFlags } from './config/flags/binary.flags.js'; import { deviceFlags } from './config/flags/device.flags.js'; @@ -10,7 +12,6 @@ import { environmentFlags } from './config/flags/environment.flags.js'; import { executionFlags } from './config/flags/execution.flags.js'; import { githubFlags } from './config/flags/github.flags.js'; import { outputFlags } from './config/flags/output.flags.js'; -import type { CliArgsDef } from './utils/help.js'; /** * All flag definitions consolidated from domain-specific flag modules. @@ -24,6 +25,6 @@ export const flags = { ...executionFlags, ...githubFlags, ...outputFlags, -} as const satisfies CliArgsDef; +} as const satisfies ArgsDef; export type FlagKey = keyof typeof flags; diff --git a/src/index.ts b/src/index.ts index da07bd4..7c0da60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,6 @@ import { uploadCommand } from './commands/upload.js'; import { whoamiCommand } from './commands/whoami.js'; import { telemetry } from './services/telemetry.service.js'; import { CliError, getCliVersion, logger, wantsJson } from './utils/cli.js'; -import { withoutHiddenArgs } from './utils/help.js'; // @clack/prompts ships the US spelling ("Canceled") for its built-in // spinner/prompt cancellation message; align it with the British spelling @@ -83,18 +82,12 @@ async function resolveSubCommand( return [cmd, parent]; } -// Usage for the (sub)command named in rawArgs, without the args marked hidden. -async function showHelp(rawArgs: string[]): Promise { - const [cmd, parent] = await resolveSubCommand(main, rawArgs); - await showUsage(await withoutHiddenArgs(cmd), parent); -} - async function run(): Promise { const rawArgs = process.argv.slice(2); telemetry.recordCommandStart(); try { if (rawArgs.includes('--help') || rawArgs.includes('-h')) { - await showHelp(rawArgs); + await showUsage(...(await resolveSubCommand(main, rawArgs))); } else if (rawArgs.length === 1 && rawArgs[0] === '--version') { logger.log(getCliVersion()); } else { @@ -110,7 +103,7 @@ async function run(): Promise { // citty throws CLIError (by name — the class isn't exported) for usage // problems like unknown commands or missing required args. if (!json && error instanceof Error && error.name === 'CLIError') { - await showHelp(rawArgs); + await showUsage(...(await resolveSubCommand(main, rawArgs))); } const exitCode = error instanceof CliError ? error.exitCode : 1; diff --git a/src/mcp/tools/run-cloud-test.ts b/src/mcp/tools/run-cloud-test.ts index c57137f..3bb3eec 100644 --- a/src/mcp/tools/run-cloud-test.ts +++ b/src/mcp/tools/run-cloud-test.ts @@ -103,7 +103,7 @@ export function registerRunCloudTest(server: McpServer): void { .boolean() .optional() .describe( - 'true encrypts the binary, flow zip and env vars client-side before upload; false never does. Omit to follow DCD_ENCRYPT, then the organization default.', + 'true encrypts the binary, flow zip and env vars client-side before upload; false never does. Omit to follow DCD_ENCRYPT, then the default, which is on.', ), wait: z .boolean() @@ -206,7 +206,7 @@ export function registerRunCloudTest(server: McpServer): void { } // Client-side envelope encryption (binary/flow/env), in the same order - // as `dcd cloud`: the encrypt input, DCD_ENCRYPT, then the org default. + // as `dcd cloud`: the encrypt input, DCD_ENCRYPT, then the server default. const encryption = checkKekForEncryption( resolveEncryption({ flag: args.encrypt, @@ -225,7 +225,7 @@ export function registerRunCloudTest(server: McpServer): void { }; telemetryExtra.encrypt = encryptTelemetry; if (encrypt && encryption.source === 'server') { - logStderr('Encrypting uploads (beta); opt out with encrypt: false'); + logStderr('Encrypting uploads; opt out with encrypt: false'); } // Resolve the binary: existing id, or upload the local file. diff --git a/src/methods.ts b/src/methods.ts index d4eb213..8d8ffca 100644 --- a/src/methods.ts +++ b/src/methods.ts @@ -281,7 +281,7 @@ async function encryptUploadSource( const kek = resolveKekPublicKey(apiUrl); if (!kek) { throw new Error( - 'Encryption is on (from --encrypt, DCD_ENCRYPT or the organization default) but no KEK public key is configured for this API. ' + + 'Encryption is on (from --encrypt, DCD_ENCRYPT or the default) but no KEK public key is configured for this API. ' + 'Set DCD_BINARY_KEK_PUBLIC=:, or pass --no-encrypt to upload without encryption.', ); } diff --git a/src/services/test-submission.service.ts b/src/services/test-submission.service.ts index 367208b..abb291f 100644 --- a/src/services/test-submission.service.ts +++ b/src/services/test-submission.service.ts @@ -241,7 +241,7 @@ export class TestSubmissionService { const kek = resolveKekPublicKey(apiUrl); if (!kek) { throw new Error( - 'Encryption is on (from --encrypt, DCD_ENCRYPT or the organization default) but no KEK public key is configured for this API. ' + + 'Encryption is on (from --encrypt, DCD_ENCRYPT or the default) but no KEK public key is configured for this API. ' + 'Set DCD_BINARY_KEK_PUBLIC=:, or pass --no-encrypt to upload without encryption.', ); } diff --git a/src/utils/compatibility.ts b/src/utils/compatibility.ts index 9df0285..0440087 100644 --- a/src/utils/compatibility.ts +++ b/src/utils/compatibility.ts @@ -13,9 +13,8 @@ export interface CompatibilityData { /** Active CLI notices, piggybacked onto the compatibility response by the API. */ notices?: Notice[]; /** - * The org's client-side encryption default (dcd#1138): on for orgs in the - * `client_encryption` beta unless the API's kill switch is set. An older API - * omits it, which means off. + * The client-side encryption default (dcd#1138): on for every org unless the + * API's kill switch is set. An older API omits it, which means off. */ encryption?: { defaultOn: boolean }; } diff --git a/src/utils/envelope.ts b/src/utils/envelope.ts index 751243b..a8ef2d1 100644 --- a/src/utils/envelope.ts +++ b/src/utils/envelope.ts @@ -87,7 +87,7 @@ function parseEncryptEnv(value: string | undefined): boolean | undefined { * 1. `flag`: `--encrypt` / `--no-encrypt`, or the MCP tool's `encrypt` input. * 2. `DCD_ENCRYPT`, then its legacy alias `DCD_ENCRYPT_BINARIES`. * 3. `serverDefault`: `encryption.defaultOn` from the compatibility data, - * which the API turns on for orgs in the `client_encryption` beta. + * which the API turns on for every org unless its kill switch is set. * 4. Off. An API too old to send `encryption` ends up here. * When on, the binary, the flow zip, and the env map are each encrypted with * their **own** per-upload DEK (all wrapped under the same per-env KEK). @@ -112,7 +112,7 @@ export function resolveEncryption(opts: { * Check that `apiUrl` has a KEK before a run encrypts against it: either * `DCD_BINARY_KEK_PUBLIC` is set or the URL is a known devicecloud.dev API * (see kekEnvForApiUrl). Without one, an explicit `--encrypt` is an error, - * while encryption from DCD_ENCRYPT or the org default is skipped with a + * while encryption from DCD_ENCRYPT or the server default is skipped with a * notice, so a run against another API URL uploads in plaintext as before. */ export function checkKekForEncryption( diff --git a/src/utils/help.ts b/src/utils/help.ts deleted file mode 100644 index 3560332..0000000 --- a/src/utils/help.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { ArgDef, ArgsDef, CommandDef } from 'citty'; - -/** - * A citty arg definition plus a local `hidden` marker. citty (0.2) can hide a - * command from help (`meta.hidden`) but not an arg, so {@link withoutHiddenArgs} - * drops hidden args before usage is rendered. They still parse as normal. - */ -export type CliArgDef = ArgDef & { hidden?: boolean }; -export type CliArgsDef = Record; - -/** `cmd` with its hidden args left out, for rendering `--help` only. */ -export async function withoutHiddenArgs( - cmd: CommandDef, -): Promise> { - const args = typeof cmd.args === 'function' ? await cmd.args() : await cmd.args; - if (!args) return cmd; - - const visible = Object.fromEntries( - Object.entries(args).filter(([, def]) => !(def as CliArgDef).hidden), - ) as T; - return { ...cmd, args: visible }; -} diff --git a/test/unit/hidden-help.test.ts b/test/unit/encrypt-help.test.ts similarity index 63% rename from test/unit/hidden-help.test.ts rename to test/unit/encrypt-help.test.ts index 89029ea..871ce2a 100644 --- a/test/unit/hidden-help.test.ts +++ b/test/unit/encrypt-help.test.ts @@ -7,11 +7,10 @@ import * as path from 'node:path'; import { cloudCommand } from '../../src/commands/cloud.js'; import { uploadCommand } from '../../src/commands/upload.js'; -import { withoutHiddenArgs } from '../../src/utils/help.js'; /** - * While client-side encryption is a per-org beta (dcd#1138), --encrypt and - * --no-encrypt are left out of --help but must still parse. + * Client-side encryption is on by default (dcd#1138), so --encrypt is listed + * in --help, and --no-encrypt, the per-run opt-out, must parse. */ const commands: Array<[string, CommandDef]> = [ ['cloud', cloudCommand as CommandDef], @@ -22,21 +21,16 @@ async function argsOf(cmd: CommandDef): Promise { return (typeof cmd.args === 'function' ? await cmd.args() : await cmd.args) ?? {}; } -describe('hidden --encrypt flag', () => { +describe('--encrypt flag', () => { for (const [name, cmd] of commands) { describe(`dcd ${name}`, () => { - it('leaves encrypt out of the usage', async () => { - const usage = await renderUsage(await withoutHiddenArgs(cmd)); - expect(usage).to.not.match(/encrypt/i); - // Everything else still renders. - expect(usage).to.contain('--api-key'); + it('lists encrypt in the usage, with the opt-out', async () => { + const usage = await renderUsage(cmd); + expect(usage).to.contain('--encrypt'); + expect(usage).to.contain('--no-encrypt or DCD_ENCRYPT=0 turns it off'); }); - it('would list it without the filter, so the test above means something', async () => { - expect(await renderUsage(cmd)).to.contain('--encrypt'); - }); - - it('still parses --encrypt and --no-encrypt', async () => { + it('parses --encrypt and --no-encrypt', async () => { const args = await argsOf(cmd); expect(parseArgs(['--encrypt'], args).encrypt).to.equal(true); expect(parseArgs(['--no-encrypt'], args).encrypt).to.equal(false); @@ -45,11 +39,6 @@ describe('hidden --encrypt flag', () => { }); } - it('does not change the command it filters', async () => { - await withoutHiddenArgs(cloudCommand as CommandDef); - expect(await argsOf(cloudCommand as CommandDef)).to.have.property('encrypt'); - }); - describe('through the real entry point', () => { let configDir: string; @@ -62,7 +51,7 @@ describe('hidden --encrypt flag', () => { }); for (const name of ['cloud', 'upload']) { - it(`dcd ${name} --help does not mention encrypt`, () => { + it(`dcd ${name} --help lists --encrypt`, () => { const out = execFileSync( process.execPath, ['--import', 'tsx/esm', path.resolve('src/index.ts'), name, '--help'], @@ -77,7 +66,7 @@ describe('hidden --encrypt flag', () => { }, ); expect(out).to.contain(`dcd ${name}`); - expect(out).to.not.match(/encrypt/i); + expect(out).to.contain('--encrypt'); }); } }); diff --git a/test/unit/encryption-decision.test.ts b/test/unit/encryption-decision.test.ts index cda1aa7..2621498 100644 --- a/test/unit/encryption-decision.test.ts +++ b/test/unit/encryption-decision.test.ts @@ -7,7 +7,7 @@ import { /** * The order that decides whether a run encrypts (dcd#1138): an explicit flag, - * then DCD_ENCRYPT (or its legacy alias), then the org's default from the + * then DCD_ENCRYPT (or its legacy alias), then the server default from the * compatibility data, then off. Every case passes `env` explicitly so the * developer's own shell can't leak in. */ diff --git a/test/unit/upload-encrypt.test.ts b/test/unit/upload-encrypt.test.ts index a5a1c26..35f1cb5 100644 --- a/test/unit/upload-encrypt.test.ts +++ b/test/unit/upload-encrypt.test.ts @@ -10,7 +10,7 @@ import { clearCompatibilityCache } from '../../src/utils/compatibility.js'; /** * `dcd upload` decides encryption in the same order as `dcd cloud`: the flag - * (--encrypt / --no-encrypt), then DCD_ENCRYPT, then the org's default from + * (--encrypt / --no-encrypt), then DCD_ENCRYPT, then the server default from * the compatibility data (dcd#1138), then off. * * Observed through the dedup lookup, which is the first upload request either @@ -21,6 +21,8 @@ const API = 'http://localhost:9999'; /** Not a devicecloud.dev API, so no pinned KEK applies. */ const UNKNOWN_API = 'https://api.example.com'; const APK = path.join(process.cwd(), 'test/fixtures/wikipedia.apk'); +/** Printed when the server default, not a flag or env var, turned encryption on. */ +const DEFAULT_ON_NOTICE = 'Encrypting uploads; opt out with --no-encrypt'; describe('dcd upload encryption', () => { const ORIGINAL_ENV = { ...process.env }; @@ -141,17 +143,17 @@ describe('dcd upload encryption', () => { expect(lookups[0]).to.not.have.property('encrypted'); }); - it('uploads in plaintext when the org default is off', async () => { + it('uploads in plaintext when the server default is off', async () => { compatEncryption = { defaultOn: false }; expect(await upload()).to.equal('plain-binary'); }); - it('encrypts when the org default is on', async () => { + it('encrypts when the server default is on', async () => { compatEncryption = { defaultOn: true }; expect(await upload()).to.equal('encrypted-binary'); }); - it('lets --no-encrypt beat the org default', async () => { + it('lets --no-encrypt beat the server default', async () => { compatEncryption = { defaultOn: true }; expect(await upload({ encrypt: false })).to.equal('plain-binary'); }); @@ -161,7 +163,7 @@ describe('dcd upload encryption', () => { expect(await upload({ encrypt: false })).to.equal('plain-binary'); }); - it('lets DCD_ENCRYPT=0 beat the org default', async () => { + it('lets DCD_ENCRYPT=0 beat the server default', async () => { process.env.DCD_ENCRYPT = '0'; compatEncryption = { defaultOn: true }; expect(await upload()).to.equal('plain-binary'); @@ -172,18 +174,16 @@ describe('dcd upload encryption', () => { expect(await upload()).to.equal('plain-binary'); }); - it('says the beta is on, and how to opt out, when the org default encrypts', async () => { + it('says it is encrypting, and how to opt out, when the server default encrypts', async () => { compatEncryption = { defaultOn: true }; - expect(await run({})).to.contain( - 'Encrypting uploads (beta); opt out with --no-encrypt', - ); + expect(await run({})).to.contain(DEFAULT_ON_NOTICE); }); - it('says nothing about the beta when the flag or env var turned encryption on', async () => { + it('prints no notice when the flag or env var turned encryption on', async () => { compatEncryption = { defaultOn: true }; - expect(await run({ encrypt: true })).to.not.contain('(beta)'); + expect(await run({ encrypt: true })).to.not.contain(DEFAULT_ON_NOTICE); process.env.DCD_ENCRYPT = '1'; - expect(await run({})).to.not.contain('(beta)'); + expect(await run({})).to.not.contain(DEFAULT_ON_NOTICE); }); describe('against an API URL with no pinned KEK', () => { @@ -191,12 +191,12 @@ describe('dcd upload encryption', () => { delete process.env.DCD_BINARY_KEK_PUBLIC; }); - it('skips the org default, with a notice, and uploads in plaintext', async () => { + it('skips the server default, with a notice, and uploads in plaintext', async () => { compatEncryption = { defaultOn: true }; expect(await upload({ 'api-url': UNKNOWN_API })).to.equal('plain-binary'); const out = await run({ 'api-url': UNKNOWN_API }); expect(out).to.contain(`Not encrypting uploads: ${UNKNOWN_API}`); - expect(out).to.not.contain('(beta)'); + expect(out).to.not.contain(DEFAULT_ON_NOTICE); }); it('skips DCD_ENCRYPT=1 the same way', async () => { From 452324ba6f6313fe9e4d418c3db655e858f79540 Mon Sep 17 00:00:00 2001 From: finalerock44 <77282157+finalerock44@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:01:43 +0100 Subject: [PATCH 3/5] feat: fetch the KEK public key from the API instead of pinning it (#251) The CLI pinned one KEK public key per environment, so every KEK rotation needed a CLI release that customers then had to pick up. GET /cli-config now publishes the newest KEK the API holds the private half of (binary_kek_public_key), and the CLI wraps uploads with whatever the API it talks to serves, fetched once per process alongside the Supabase key. DCD_BINARY_KEK_PUBLIC still overrides for an API that publishes nothing. An API that publishes no key keeps the old behaviour (explicit --encrypt fails, env/org-default encryption skips with a notice); an API that cannot be asked at all fails the run rather than silently uploading plaintext. --- src/commands/cloud.ts | 2 +- src/commands/upload.ts | 2 +- src/config/cli-config.ts | 61 ++++++++ src/config/environments.ts | 46 +----- src/config/supabase-key.ts | 40 ++--- src/mcp/tools/run-cloud-test.ts | 2 +- src/methods.ts | 4 +- src/services/test-submission.service.ts | 2 +- src/utils/envelope.ts | 61 ++++---- test/unit/encrypted-dedup.test.ts | 2 +- test/unit/envelope.test.ts | 62 +++++--- test/unit/kek-from-api.test.ts | 194 ++++++++++++++++++++++++ test/unit/kek-pinning.test.ts | 128 ---------------- test/unit/upload-encrypt.test.ts | 15 +- 14 files changed, 363 insertions(+), 258 deletions(-) create mode 100644 src/config/cli-config.ts create mode 100644 test/unit/kek-from-api.test.ts delete mode 100644 test/unit/kek-pinning.test.ts diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index 92fe2fb..fc3f04f 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -390,7 +390,7 @@ export const cloudCommand = defineCommand({ out(`[DEBUG] API URL: ${apiUrl}`); } - const encryption = checkKekForEncryption( + const encryption = await checkKekForEncryption( resolveEncryption({ flag: encryptFlag, serverDefault: compatibilityData.encryption?.defaultOn, diff --git a/src/commands/upload.ts b/src/commands/upload.ts index 792e49c..ffa1359 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -75,7 +75,7 @@ export const uploadCommand = defineCommand({ } } - const encryption = checkKekForEncryption( + const encryption = await checkKekForEncryption( resolveEncryption({ flag: encryptFlag, serverDefault }), apiUrl, ); diff --git a/src/config/cli-config.ts b/src/config/cli-config.ts new file mode 100644 index 0000000..2937adb --- /dev/null +++ b/src/config/cli-config.ts @@ -0,0 +1,61 @@ +/** + * The dcd API's public `GET /cli-config`: runtime configuration the CLI used to + * compile in, fetched from the API it talks to so that rotating any of it is an + * env change on the API rather than a CLI release. + * + * - `supabase_publishable_key` (dcd#1112): see ./supabase-key.ts. + * - `binary_kek_public_key` (dcd#1138): the newest KEK public key the API + * publishes for client-side envelope encryption, with the version the API + * unwraps it with. Absent when the API publishes none. + * + * Fetched once per process per API URL and kept in memory only. It is + * deliberately not cached on disk: a stale Supabase key makes GoTrue answer a + * refresh with a 4xx, which `resolveAuth` treats as a dead session and deletes + * the user's login, and a stale KEK would wrap uploads the API can no longer + * unwrap. A failed fetch is not cached, so the next call retries. + */ + +const FETCH_TIMEOUT_MS = 10_000; + +export interface CliConfig { + supabase_url?: string; + supabase_publishable_key?: string; + binary_kek_public_key?: { version: number; key: string }; +} + +const cache = new Map>(); + +/** `apiUrl` without trailing slashes, so `…/` and `…` share one cache entry. */ +export function cliConfigUrl(apiUrl: string): string { + return `${apiUrl.replace(/\/+$/, '')}/cli-config`; +} + +async function fetchCliConfig(apiUrl: string): Promise { + const url = cliConfigUrl(apiUrl); + const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!res.ok) { + throw new Error(`Failed to fetch CLI config from ${url}: HTTP ${res.status}`); + } + const body: unknown = await res.json(); + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new Error(`CLI config from ${url} is not a JSON object`); + } + return body as CliConfig; +} + +/** The API's CLI config. A failed fetch is not cached, so the next call retries. */ +export function getCliConfig(apiUrl: string): Promise { + const key = cliConfigUrl(apiUrl); + let pending = cache.get(key); + if (!pending) { + pending = fetchCliConfig(apiUrl); + cache.set(key, pending); + pending.catch(() => cache.delete(key)); + } + return pending; +} + +/** Test seam: forget fetched configs. */ +export function resetCliConfigCache(): void { + cache.clear(); +} diff --git a/src/config/environments.ts b/src/config/environments.ts index dfdc2db..1d79cd7 100644 --- a/src/config/environments.ts +++ b/src/config/environments.ts @@ -2,9 +2,11 @@ * Per-env devicecloud.dev coordinates — single source of truth for the URLs * the CLI uses. * - * There is deliberately no Supabase key here. A compiled-in key can never be - * rotated without breaking every pinned CLI version, so it is fetched from the - * API at runtime instead: see `getSupabaseKey` in ./supabase-key.ts (dcd#1112). + * There is deliberately no Supabase key and no KEK public key here. A + * compiled-in key can never be rotated without breaking every pinned CLI + * version, so both are fetched from the API at runtime instead: see + * ./cli-config.ts (`getSupabaseKey` in ./supabase-key.ts for dcd#1112, and + * `resolveKekPublicKey` in ../utils/envelope.ts for dcd#1138). */ export type DcdEnvName = 'dev' | 'prod'; @@ -16,21 +18,6 @@ export interface DcdEnvironment { url: string; projectRef: string; }; - /** - * Pinned KEK public key for client-side binary envelope encryption (dcd#1138). - * `key` is base64 of the raw 32-byte X25519 public key; `version` selects which - * KEK the platform API unwraps with. **Public — safe to embed**: it can only - * *encrypt*; the private half lives solely on the API. - * - * Both environments pin v1. To rotate, generate a keypair with, e.g.: - * node -e 'const c=require("crypto");const{publicKey,privateKey}=c.generateKeyPairSync("x25519");const pub=publicKey.export({type:"spki",format:"der"}).subarray(12);const priv=privateKey.export({type:"pkcs8",format:"der"}).subarray(16);console.log("public :",pub.toString("base64"));console.log("private:",priv.toString("base64"))' - * Add `private` to the API's `BINARY_KEK_PRIVATE_KEYS` JSON map next to the - * versions already there (dropping one bricks its uploads), keep one offline - * escrow copy, add `public` to dcd's `PINNED_KEK_PUBLIC_KEYS`, then pin it - * here with the new version. `null` pins nothing, so encryption then needs - * the `DCD_BINARY_KEK_PUBLIC` env override (see envelope.ts). - */ - kekPublicKey: { version: number; key: string } | null; } export const ENVIRONMENTS: Record = { @@ -41,7 +28,6 @@ export const ENVIRONMENTS: Record = { url: 'https://cloud.devicecloud.dev', projectRef: 'pgydnphbimetinsgfkbo', }, - kekPublicKey: { version: 1, key: 'wtfyWEwK7nJzwI4PD+9RAW8jxIR1u8kMQq2IhsrVnH4=' }, }, dev: { apiUrl: 'https://api.dev.devicecloud.dev', @@ -50,7 +36,6 @@ export const ENVIRONMENTS: Record = { url: 'https://lbmsowehtjwnqlurpemb.supabase.co', projectRef: 'lbmsowehtjwnqlurpemb', }, - kekPublicKey: { version: 1, key: 'RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=' }, }, }; @@ -69,27 +54,6 @@ export function inferEnvFromApiUrl(apiUrl: string): DcdEnvName { return 'prod'; } -/** - * The environment whose pinned KEK may encrypt uploads to `apiUrl` (dcd#1138), - * or undefined when the URL isn't a known one. Stricter than - * {@link inferEnvFromApiUrl}, which falls back to prod for any URL: that is - * harmless for picking a console link, but an upload encrypted for a KEK its - * API can't unwrap is unusable. Known means the host of a pinned `apiUrl`, or - * `localhost`, which runs against dev. - */ -export function kekEnvForApiUrl(apiUrl: string): DcdEnvName | undefined { - let host: string; - try { - host = new URL(apiUrl).hostname; - } catch { - return undefined; - } - - if (host === 'localhost') return 'dev'; - const names = Object.keys(ENVIRONMENTS) as DcdEnvName[]; - return names.find((name) => new URL(ENVIRONMENTS[name].apiUrl).hostname === host); -} - /** * Frontend URL to open for a given API URL. Same as * `ENVIRONMENTS[inferEnvFromApiUrl(apiUrl)].frontendUrl`, except that when the diff --git a/src/config/supabase-key.ts b/src/config/supabase-key.ts index 5f3ca42..d09ecff 100644 --- a/src/config/supabase-key.ts +++ b/src/config/supabase-key.ts @@ -3,48 +3,26 @@ * * Releases up to this one compiled the anon key in, so the key could never be * rotated without breaking every CLI version pinned in customer CI. The API now - * serves it from the public `GET /cli-config`, and rotating it is an env change - * on the API. - * - * Fetched once per process and kept in memory only. It is deliberately not - * cached on disk: a stale key makes GoTrue answer a refresh with a 4xx, which - * `resolveAuth` treats as a dead session and deletes the user's login. + * serves it from the public `GET /cli-config` (see ./cli-config.ts for the + * fetch and its in-memory cache), and rotating it is an env change on the API. * * Only the key comes from the API. The Supabase URLs stay pinned in * `ENVIRONMENTS`: prod goes through the `cloud.devicecloud.dev` custom domain, * which customer firewalls may allow-list, not the API's raw `supabase.co` URL. */ +import { cliConfigUrl, getCliConfig, resetCliConfigCache } from './cli-config.js'; import { ENVIRONMENTS, type DcdEnvName } from './environments.js'; -const FETCH_TIMEOUT_MS = 10_000; - -const cache = new Map>(); - -async function fetchSupabaseKey(env: DcdEnvName): Promise { - const url = `${ENVIRONMENTS[env].apiUrl}/cli-config`; - const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); - if (!res.ok) { - throw new Error(`Failed to fetch CLI config from ${url}: HTTP ${res.status}`); - } - const body = (await res.json()) as { supabase_publishable_key?: unknown }; - const key = body.supabase_publishable_key; +/** The publishable key for `env`. A failed fetch is not cached, so the next call retries. */ +export async function getSupabaseKey(env: DcdEnvName): Promise { + const apiUrl = ENVIRONMENTS[env].apiUrl; + const key = (await getCliConfig(apiUrl)).supabase_publishable_key; if (typeof key !== 'string' || key.length === 0) { - throw new Error(`CLI config from ${url} has no supabase_publishable_key`); + throw new Error(`CLI config from ${cliConfigUrl(apiUrl)} has no supabase_publishable_key`); } return key; } -/** The publishable key for `env`. A failed fetch is not cached, so the next call retries. */ -export function getSupabaseKey(env: DcdEnvName): Promise { - let pending = cache.get(env); - if (!pending) { - pending = fetchSupabaseKey(env); - cache.set(env, pending); - pending.catch(() => cache.delete(env)); - } - return pending; -} - /** * Headers that authenticate a raw Storage/Auth request with `key` alone. A * legacy anon key is a JWT and also goes in `Authorization`; an @@ -56,5 +34,5 @@ export function supabaseKeyHeaders(key: string): Record { /** Test seam: forget fetched keys. */ export function resetSupabaseKeyCache(): void { - cache.clear(); + resetCliConfigCache(); } diff --git a/src/mcp/tools/run-cloud-test.ts b/src/mcp/tools/run-cloud-test.ts index 3bb3eec..1d32845 100644 --- a/src/mcp/tools/run-cloud-test.ts +++ b/src/mcp/tools/run-cloud-test.ts @@ -207,7 +207,7 @@ export function registerRunCloudTest(server: McpServer): void { // Client-side envelope encryption (binary/flow/env), in the same order // as `dcd cloud`: the encrypt input, DCD_ENCRYPT, then the server default. - const encryption = checkKekForEncryption( + const encryption = await checkKekForEncryption( resolveEncryption({ flag: args.encrypt, serverDefault: compatibilityData.encryption?.defaultOn, diff --git a/src/methods.ts b/src/methods.ts index 8d8ffca..3634c06 100644 --- a/src/methods.ts +++ b/src/methods.ts @@ -268,7 +268,7 @@ export const uploadBinary = async (config: UploadBinaryConfig) => { /** * Encrypt the prepared upload source in place (dcd#1138): generate a per-upload * DEK, stream-encrypt `source.diskPath` into a temp ciphertext file, wrap the - * DEK with the environment's pinned KEK public key, and repoint `source` at the + * DEK with the KEK public key the API publishes, and repoint `source` at the * ciphertext so the SHA, dedup check, and both uploaders operate on ciphertext. * Returns the envelope metadata (for `binaries.metadata.enc`) plus the temp dir * to clean up. Throws if no KEK is available for the environment. @@ -278,7 +278,7 @@ async function encryptUploadSource( apiUrl: string, debug: boolean, ): Promise<{ enc: BinaryEnvelope; cleanupDir: string }> { - const kek = resolveKekPublicKey(apiUrl); + const kek = await resolveKekPublicKey(apiUrl); if (!kek) { throw new Error( 'Encryption is on (from --encrypt, DCD_ENCRYPT or the default) but no KEK public key is configured for this API. ' + diff --git a/src/services/test-submission.service.ts b/src/services/test-submission.service.ts index abb291f..004d23d 100644 --- a/src/services/test-submission.service.ts +++ b/src/services/test-submission.service.ts @@ -238,7 +238,7 @@ export class TestSubmissionService { if (!apiUrl) { throw new Error('Encryption requires apiUrl to resolve the KEK public key'); } - const kek = resolveKekPublicKey(apiUrl); + const kek = await resolveKekPublicKey(apiUrl); if (!kek) { throw new Error( 'Encryption is on (from --encrypt, DCD_ENCRYPT or the default) but no KEK public key is configured for this API. ' + diff --git a/src/utils/envelope.ts b/src/utils/envelope.ts index a8ef2d1..d611d8e 100644 --- a/src/utils/envelope.ts +++ b/src/utils/envelope.ts @@ -7,7 +7,7 @@ import { randomBytes, } from 'node:crypto'; import { open, stat } from 'node:fs/promises'; -import { ENVIRONMENTS, kekEnvForApiUrl } from '../config/environments.js'; +import { cliConfigUrl, getCliConfig } from '../config/cli-config.js'; /** * Client-side envelope encryption of app binaries before upload (dcd#1138). @@ -18,9 +18,9 @@ import { ENVIRONMENTS, kekEnvForApiUrl } from '../config/environments.js'; * specified in `dcd/docs/binary-envelope-encryption.md`. * * Scheme: a per-upload random 256-bit DEK encrypts the binary (chunked - * AES-256-GCM); the DEK is wrapped with the environment's pinned X25519 KEK - * public key (sealed box). Only the platform API holds the KEK private half, so - * every storage/transport tier sees ciphertext only. + * AES-256-GCM); the DEK is wrapped with the X25519 KEK public key the API + * publishes on `GET /cli-config` (sealed box). Only the platform API holds the + * KEK private half, so every storage/transport tier sees ciphertext only. */ const MAGIC = Buffer.from('DCDE', 'ascii'); @@ -110,32 +110,35 @@ export function resolveEncryption(opts: { /** * Check that `apiUrl` has a KEK before a run encrypts against it: either - * `DCD_BINARY_KEK_PUBLIC` is set or the URL is a known devicecloud.dev API - * (see kekEnvForApiUrl). Without one, an explicit `--encrypt` is an error, + * `DCD_BINARY_KEK_PUBLIC` is set or the API publishes one on `GET /cli-config` + * (see resolveKekPublicKey). Without one, an explicit `--encrypt` is an error, * while encryption from DCD_ENCRYPT or the server default is skipped with a - * notice, so a run against another API URL uploads in plaintext as before. + * notice, so a run against an API that publishes no KEK uploads in plaintext + * as before. An API that cannot be asked at all is an error whatever decided + * encryption: silently falling back to plaintext would be the one outcome the + * org asked not to have. */ -export function checkKekForEncryption( +export async function checkKekForEncryption( decision: EncryptionDecision, apiUrl: string, -): EncryptionDecision { - if (!decision.enabled || resolveKekPublicKey(apiUrl)) return decision; +): Promise { + if (!decision.enabled || (await resolveKekPublicKey(apiUrl))) return decision; if (decision.source === 'flag') { throw new Error( - `--encrypt needs the KEK public key of the API it uploads to, and ${apiUrl} is not a devicecloud.dev API this CLI has one for. ` + + `--encrypt needs the KEK public key of the API it uploads to, and ${apiUrl} does not publish one. ` + 'Set DCD_BINARY_KEK_PUBLIC=: to encrypt against it, or drop --encrypt.', ); } return { enabled: false, - notice: `Not encrypting uploads: ${apiUrl} is not a devicecloud.dev API this CLI has a key for. Set DCD_BINARY_KEK_PUBLIC to encrypt against it.`, + notice: `Not encrypting uploads: ${apiUrl} does not publish a KEK public key. Set DCD_BINARY_KEK_PUBLIC to encrypt against it.`, source: decision.source, }; } -/** Pinned KEK public key (base64 raw 32-byte X25519) + version, per env. */ +/** KEK public key (raw 32-byte X25519) + the version the API unwraps it with. */ interface KekPublicKey { version: number; keyRaw: Buffer; @@ -150,14 +153,17 @@ function x25519PublicFromRaw(raw: Buffer) { } /** - * Resolve the KEK public key for the environment behind `apiUrl`. Order: + * Resolve the KEK public key for the API behind `apiUrl`. Order: * 1. `DCD_BINARY_KEK_PUBLIC` env override (`:`, e.g. `1:AAAA…`) - * — lets the feature be exercised before keys are pinned in the release, - * or against an API URL that isn't a known one. - * 2. the pinned `ENVIRONMENTS[env].kekPublicKey`, only for a known API URL. - * Returns null when no key is available (encryption cannot proceed). + * — lets the feature be exercised against an API that publishes nothing. + * 2. `binary_kek_public_key` from the API's own `GET /cli-config` (dcd#1138): + * the newest KEK the API holds the private half of. Fetched once per + * process (see ../config/cli-config.ts), so a KEK rotation is an env + * change on the API and never a CLI release. + * Returns null when the API publishes no key (encryption cannot proceed). + * Throws when the API cannot be asked, or publishes something malformed. */ -export function resolveKekPublicKey(apiUrl: string): KekPublicKey | null { +export async function resolveKekPublicKey(apiUrl: string): Promise { const override = process.env.DCD_BINARY_KEK_PUBLIC; if (override) { const [versionPart, b64] = override.split(':'); @@ -171,15 +177,16 @@ export function resolveKekPublicKey(apiUrl: string): KekPublicKey | null { ); } - const env = kekEnvForApiUrl(apiUrl); - if (!env) return null; - const pinned = ENVIRONMENTS[env].kekPublicKey; - if (!pinned) return null; - const keyRaw = Buffer.from(pinned.key, 'base64'); - if (keyRaw.length !== 32) { - throw new Error(`Pinned KEK public key for ${env} is not a 32-byte key`); + const published = (await getCliConfig(apiUrl)).binary_kek_public_key; + if (published === undefined || published === null) return null; + const { version, key } = published as { version?: unknown; key?: unknown }; + const keyRaw = typeof key === 'string' ? Buffer.from(key, 'base64') : Buffer.alloc(0); + if (!Number.isInteger(version) || keyRaw.length !== 32) { + throw new Error( + `CLI config from ${cliConfigUrl(apiUrl)} has a malformed binary_kek_public_key; expected { version: integer, key: base64 32-byte X25519 public key }`, + ); } - return { version: pinned.version, keyRaw }; + return { version: version as number, keyRaw }; } /** diff --git a/test/unit/encrypted-dedup.test.ts b/test/unit/encrypted-dedup.test.ts index 47e4c5c..65ba6ee 100644 --- a/test/unit/encrypted-dedup.test.ts +++ b/test/unit/encrypted-dedup.test.ts @@ -66,7 +66,7 @@ function mockFetch( return calls; } -/** A throwaway X25519 public key so encryption can run without a pinned KEK. */ +/** A throwaway X25519 public key so encryption can run without asking an API for one. */ function setTestKek() { const { publicKey } = generateKeyPairSync('x25519'); const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(12); diff --git a/test/unit/envelope.test.ts b/test/unit/envelope.test.ts index 2c8f1a0..36cfce1 100644 --- a/test/unit/envelope.test.ts +++ b/test/unit/envelope.test.ts @@ -1,4 +1,6 @@ import { expect } from 'chai'; + +import { resetCliConfigCache } from '../../src/config/cli-config.js'; import { createDecipheriv, createPublicKey, @@ -160,10 +162,10 @@ describe('binary envelope encryption (#1138)', () => { expect(() => refDecryptContainer(ciphertext, dek)).to.throw(); }); - it('wraps a DEK that the matching KEK private key unwraps', () => { + it('wraps a DEK that the matching KEK private key unwraps', async () => { const { privRaw, pubRaw } = rawX25519(); process.env.DCD_BINARY_KEK_PUBLIC = `3:${pubRaw.toString('base64')}`; - const kek = resolveKekPublicKey('https://api.devicecloud.dev'); + const kek = await resolveKekPublicKey('https://api.devicecloud.dev'); expect(kek).to.not.equal(null); const dek = generateDek(); @@ -175,20 +177,38 @@ describe('binary envelope encryption (#1138)', () => { ); }); - it('resolves the pinned KEK public key for each environment', () => { - const prod = resolveKekPublicKey('https://api.devicecloud.dev'); - expect(prod).to.not.equal(null); - expect(prod!.version).to.equal(1); - expect(prod!.keyRaw.toString('base64')).to.equal( - 'wtfyWEwK7nJzwI4PD+9RAW8jxIR1u8kMQq2IhsrVnH4=', - ); + it('resolves the KEK public key the API publishes on /cli-config', async () => { + const { privRaw, pubRaw } = rawX25519(); + const realFetch = globalThis.fetch; + const requested: string[] = []; + globalThis.fetch = (async (input: string | URL | Request) => { + requested.push(String(input)); + return new Response( + JSON.stringify({ + supabase_publishable_key: 'sb_publishable_x', + binary_kek_public_key: { version: 4, key: pubRaw.toString('base64') }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }) as typeof fetch; + try { + resetCliConfigCache(); + const kek = await resolveKekPublicKey('https://api.example.test/'); + expect(kek).to.not.equal(null); + expect(kek!.version).to.equal(4); + expect(kek!.keyRaw.equals(pubRaw)).to.equal(true); + expect(requested).to.deep.equal(['https://api.example.test/cli-config']); - const dev = resolveKekPublicKey('https://api.dev.devicecloud.dev'); - expect(dev).to.not.equal(null); - expect(dev!.version).to.equal(1); - expect(dev!.keyRaw.toString('base64')).to.equal( - 'RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=', - ); + const dek = generateDek(); + const envelope = wrapDek(dek, kek!); + expect(envelope.kek).to.equal(4); + expect(refUnwrapDek(envelope.wrapped_key, privRaw).equals(dek)).to.equal( + true, + ); + } finally { + globalThis.fetch = realFetch; + resetCliConfigCache(); + } }); }); @@ -197,10 +217,10 @@ describe('flow + env envelope encryption (#1151, #1152)', () => { delete process.env.DCD_BINARY_KEK_PUBLIC; }); - it('encryptFlowBuffer produces a container the platform decrypts to the original zip', () => { + it('encryptFlowBuffer produces a container the platform decrypts to the original zip', async () => { const { privRaw, pubRaw } = rawX25519(); process.env.DCD_BINARY_KEK_PUBLIC = `2:${pubRaw.toString('base64')}`; - const kek = resolveKekPublicKey('https://api.dev.devicecloud.dev')!; + const kek = (await resolveKekPublicKey('https://api.dev.devicecloud.dev'))!; const zip = randomBytes(5000); const { ciphertext, enc } = encryptFlowBuffer(zip, kek); @@ -212,10 +232,10 @@ describe('flow + env envelope encryption (#1151, #1152)', () => { expect(refDecryptContainer(ciphertext, dek).equals(zip)).to.equal(true); }); - it('encryptEnv produces an inline envelope the platform decrypts back to the map', () => { + it('encryptEnv produces an inline envelope the platform decrypts back to the map', async () => { const { privRaw, pubRaw } = rawX25519(); process.env.DCD_BINARY_KEK_PUBLIC = `1:${pubRaw.toString('base64')}`; - const kek = resolveKekPublicKey('https://api.dev.devicecloud.dev')!; + const kek = (await resolveKekPublicKey('https://api.dev.devicecloud.dev'))!; const env = { API_TOKEN: 'secret', PASSWORD: 'p@ss word=1', EMPTY: '' }; const enc = encryptEnv(env, kek); @@ -228,10 +248,10 @@ describe('flow + env envelope encryption (#1151, #1152)', () => { expect(JSON.parse(plain.toString('utf8'))).to.deep.equal(env); }); - it('gives the flow zip and env their own distinct DEKs', () => { + it('gives the flow zip and env their own distinct DEKs', async () => { const { pubRaw } = rawX25519(); process.env.DCD_BINARY_KEK_PUBLIC = `1:${pubRaw.toString('base64')}`; - const kek = resolveKekPublicKey('https://api.dev.devicecloud.dev')!; + const kek = (await resolveKekPublicKey('https://api.dev.devicecloud.dev'))!; const flow = encryptFlowBuffer(randomBytes(100), kek); const env = encryptEnv({ A: 'b' }, kek); diff --git a/test/unit/kek-from-api.test.ts b/test/unit/kek-from-api.test.ts new file mode 100644 index 0000000..a57a5b1 --- /dev/null +++ b/test/unit/kek-from-api.test.ts @@ -0,0 +1,194 @@ +import { expect } from 'chai'; +import { generateKeyPairSync } from 'node:crypto'; + +import { getCliConfig, resetCliConfigCache } from '../../src/config/cli-config.js'; +import { + checkKekForEncryption, + resolveKekPublicKey, +} from '../../src/utils/envelope.js'; + +/** + * The KEK public key comes from the API the upload goes to (dcd#1138): its + * `GET /cli-config` publishes the newest KEK it holds the private half of, so + * a rotation is an env change on the API and never a CLI release. Encrypting + * for a key the receiving API cannot unwrap produces uploads it can't decrypt, + * which is why there is no fallback to a compiled-in key any more. + */ +describe('KEK public key from /cli-config', () => { + const ORIGINAL_OVERRIDE = process.env.DCD_BINARY_KEK_PUBLIC; + const realFetch = globalThis.fetch; + let requested: string[]; + + function rawPublicKey(): string { + const { publicKey } = generateKeyPairSync('x25519'); + return publicKey.export({ format: 'der', type: 'spki' }).subarray(12).toString('base64'); + } + + function stubFetch(respond: (url: string) => Response | Promise): void { + requested = []; + globalThis.fetch = (async (input: string | URL | Request) => { + requested.push(String(input)); + return respond(String(input)); + }) as typeof fetch; + } + + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + + beforeEach(() => { + delete process.env.DCD_BINARY_KEK_PUBLIC; + resetCliConfigCache(); + }); + + afterEach(() => { + globalThis.fetch = realFetch; + resetCliConfigCache(); + if (ORIGINAL_OVERRIDE === undefined) delete process.env.DCD_BINARY_KEK_PUBLIC; + else process.env.DCD_BINARY_KEK_PUBLIC = ORIGINAL_OVERRIDE; + }); + + describe('resolveKekPublicKey', () => { + it('uses the key the API publishes, for any API URL', async () => { + const key = rawPublicKey(); + stubFetch(() => json({ binary_kek_public_key: { version: 2, key } })); + + const kek = await resolveKekPublicKey('https://my-proxy.corp.test/devicecloud'); + expect(kek?.version).to.equal(2); + expect(kek?.keyRaw.toString('base64')).to.equal(key); + expect(requested).to.deep.equal(['https://my-proxy.corp.test/devicecloud/cli-config']); + }); + + it('fetches the config once per process and shares it with the Supabase key', async () => { + const key = rawPublicKey(); + stubFetch(() => + json({ supabase_publishable_key: 'sb_publishable_x', binary_kek_public_key: { version: 1, key } }), + ); + + await resolveKekPublicKey('https://api.devicecloud.dev/'); + await resolveKekPublicKey('https://api.devicecloud.dev'); + expect((await getCliConfig('https://api.devicecloud.dev')).supabase_publishable_key).to.equal( + 'sb_publishable_x', + ); + expect(requested).to.deep.equal(['https://api.devicecloud.dev/cli-config']); + }); + + it('has no key when the API publishes none', async () => { + stubFetch(() => json({ supabase_publishable_key: 'sb_publishable_x' })); + expect(await resolveKekPublicKey('https://api.example.com')).to.equal(null); + }); + + it('rejects a malformed published key rather than wrapping with it', async () => { + stubFetch(() => json({ binary_kek_public_key: { version: 1, key: 'not-32-bytes' } })); + let message = ''; + try { + await resolveKekPublicKey('https://api.example.com'); + } catch (error) { + message = (error as Error).message; + } + expect(message).to.contain('malformed binary_kek_public_key'); + }); + + it('fails, and does not cache the failure, when the API cannot be asked', async () => { + stubFetch(() => json({}, 503)); + let failed = false; + try { + await resolveKekPublicKey('https://api.example.com'); + } catch { + failed = true; + } + expect(failed).to.equal(true); + + const key = rawPublicKey(); + stubFetch(() => json({ binary_kek_public_key: { version: 1, key } })); + expect((await resolveKekPublicKey('https://api.example.com'))?.version).to.equal(1); + }); + + it('prefers DCD_BINARY_KEK_PUBLIC without asking the API', async () => { + process.env.DCD_BINARY_KEK_PUBLIC = `7:${rawPublicKey()}`; + stubFetch(() => json({ binary_kek_public_key: { version: 1, key: rawPublicKey() } })); + + expect((await resolveKekPublicKey('https://api.example.com'))?.version).to.equal(7); + expect(requested).to.deep.equal([]); + }); + + it('rejects a malformed DCD_BINARY_KEK_PUBLIC', async () => { + process.env.DCD_BINARY_KEK_PUBLIC = 'nope'; + let message = ''; + try { + await resolveKekPublicKey('https://api.example.com'); + } catch (error) { + message = (error as Error).message; + } + expect(message).to.contain('DCD_BINARY_KEK_PUBLIC must be'); + }); + }); + + describe('checkKekForEncryption', () => { + const NO_KEK = 'https://api.example.com'; + + it('passes a decision through when the API publishes a key', async () => { + stubFetch(() => json({ binary_kek_public_key: { version: 1, key: rawPublicKey() } })); + for (const source of ['flag', 'env', 'server'] as const) { + expect( + await checkKekForEncryption({ enabled: true, source }, 'https://api.dev.devicecloud.dev'), + ).to.deep.equal({ enabled: true, source }); + } + }); + + it('passes a decision to stay off through without asking the API', async () => { + stubFetch(() => json({}, 500)); + expect(await checkKekForEncryption({ enabled: false, source: 'off' }, NO_KEK)).to.deep.equal({ + enabled: false, + source: 'off', + }); + expect(requested).to.deep.equal([]); + }); + + for (const source of ['env', 'server'] as const) { + it(`skips ${source} encryption when the API publishes no key, with a notice`, async () => { + stubFetch(() => json({ supabase_publishable_key: 'sb_publishable_x' })); + const decision = await checkKekForEncryption({ enabled: true, source }, NO_KEK); + expect(decision).to.include({ enabled: false, source }); + expect(decision.notice).to.contain(NO_KEK); + expect(decision.notice).to.contain('DCD_BINARY_KEK_PUBLIC'); + expect(decision.notice).to.not.contain('\n'); + }); + } + + it('refuses an explicit --encrypt when the API publishes no key', async () => { + stubFetch(() => json({ supabase_publishable_key: 'sb_publishable_x' })); + let message = ''; + try { + await checkKekForEncryption({ enabled: true, source: 'flag' }, NO_KEK); + } catch (error) { + message = (error as Error).message; + } + expect(message).to.match(/--encrypt needs the KEK public key.*DCD_BINARY_KEK_PUBLIC/); + }); + + it('fails closed rather than uploading plaintext when the API cannot be asked', async () => { + stubFetch(() => json({}, 503)); + for (const source of ['flag', 'env', 'server'] as const) { + let failed = false; + try { + await checkKekForEncryption({ enabled: true, source }, NO_KEK); + } catch { + failed = true; + } + expect(failed, source).to.equal(true); + } + }); + + it('lets DCD_BINARY_KEK_PUBLIC unlock any API, whatever the source', async () => { + process.env.DCD_BINARY_KEK_PUBLIC = `7:${rawPublicKey()}`; + stubFetch(() => json({}, 500)); + for (const source of ['flag', 'env', 'server'] as const) { + expect(await checkKekForEncryption({ enabled: true, source }, NO_KEK)).to.deep.equal({ + enabled: true, + source, + }); + } + expect(requested).to.deep.equal([]); + }); + }); +}); diff --git a/test/unit/kek-pinning.test.ts b/test/unit/kek-pinning.test.ts deleted file mode 100644 index 517cf5f..0000000 --- a/test/unit/kek-pinning.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { expect } from 'chai'; -import { generateKeyPairSync } from 'node:crypto'; - -import { - inferEnvFromApiUrl, - kekEnvForApiUrl, -} from '../../src/config/environments.js'; -import { - checkKekForEncryption, - resolveKekPublicKey, -} from '../../src/utils/envelope.js'; - -/** - * A pinned KEK only applies to the API it belongs to (dcd#1138). Encrypting - * for the prod KEK against any other URL, which inferEnvFromApiUrl's prod - * fallback used to do, produces uploads that API can't decrypt. - */ -describe('KEK pinning by API URL', () => { - const ORIGINAL_OVERRIDE = process.env.DCD_BINARY_KEK_PUBLIC; - - beforeEach(() => { - delete process.env.DCD_BINARY_KEK_PUBLIC; - }); - - afterEach(() => { - if (ORIGINAL_OVERRIDE === undefined) delete process.env.DCD_BINARY_KEK_PUBLIC; - else process.env.DCD_BINARY_KEK_PUBLIC = ORIGINAL_OVERRIDE; - }); - - function setTestKek() { - const { publicKey } = generateKeyPairSync('x25519'); - const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(12); - process.env.DCD_BINARY_KEK_PUBLIC = `7:${raw.toString('base64')}`; - } - - describe('kekEnvForApiUrl', () => { - const known: Array<[string, 'dev' | 'prod']> = [ - ['https://api.devicecloud.dev', 'prod'], - ['https://api.devicecloud.dev/', 'prod'], - ['https://api.dev.devicecloud.dev', 'dev'], - ['https://api.dev.devicecloud.dev/', 'dev'], - ['http://localhost:3000', 'dev'], - ['http://localhost', 'dev'], - ]; - for (const [url, env] of known) { - it(`maps ${url} to ${env}`, () => { - expect(kekEnvForApiUrl(url)).to.equal(env); - }); - } - - const unknown = [ - 'https://api.example.com', - // inferEnvFromApiUrl reads any `api.dev.` host as dev. - 'https://api.dev.example.com', - 'https://devicecloud.dev.attacker.test', - 'http://127.0.0.1:3000', - 'https://my-proxy.corp.test/devicecloud', - 'not a url', - ]; - for (const url of unknown) { - it(`does not know ${url}`, () => { - expect(kekEnvForApiUrl(url)).to.equal(undefined); - }); - } - - it('leaves inferEnvFromApiUrl falling back to prod for its other callers', () => { - expect(inferEnvFromApiUrl('https://api.example.com')).to.equal('prod'); - expect(inferEnvFromApiUrl('https://api.dev.example.com')).to.equal('dev'); - }); - }); - - describe('resolveKekPublicKey', () => { - it('has no key for an unknown API URL', () => { - expect(resolveKekPublicKey('https://api.example.com')).to.equal(null); - }); - - it('uses DCD_BINARY_KEK_PUBLIC for any URL, known or not', () => { - setTestKek(); - expect(resolveKekPublicKey('https://api.example.com')?.version).to.equal(7); - expect(resolveKekPublicKey('https://api.devicecloud.dev')?.version).to.equal(7); - }); - }); - - describe('checkKekForEncryption', () => { - const UNKNOWN = 'https://api.example.com'; - - it('passes a decision through for a known API URL', () => { - for (const source of ['flag', 'env', 'server'] as const) { - expect( - checkKekForEncryption({ enabled: true, source }, 'https://api.dev.devicecloud.dev'), - ).to.deep.equal({ enabled: true, source }); - } - }); - - it('passes a decision to stay off through, whatever the URL', () => { - expect(checkKekForEncryption({ enabled: false, source: 'off' }, UNKNOWN)).to.deep.equal({ - enabled: false, - source: 'off', - }); - }); - - for (const source of ['env', 'server'] as const) { - it(`skips ${source} encryption on an unknown URL, with a notice`, () => { - const decision = checkKekForEncryption({ enabled: true, source }, UNKNOWN); - expect(decision).to.include({ enabled: false, source }); - expect(decision.notice).to.contain(UNKNOWN); - expect(decision.notice).to.contain('DCD_BINARY_KEK_PUBLIC'); - expect(decision.notice).to.not.contain('\n'); - }); - } - - it('refuses an explicit --encrypt on an unknown URL', () => { - expect(() => - checkKekForEncryption({ enabled: true, source: 'flag' }, UNKNOWN), - ).to.throw(/--encrypt needs the KEK public key.*DCD_BINARY_KEK_PUBLIC/); - }); - - it('lets DCD_BINARY_KEK_PUBLIC unlock an unknown URL, whatever the source', () => { - setTestKek(); - for (const source of ['flag', 'env', 'server'] as const) { - expect(checkKekForEncryption({ enabled: true, source }, UNKNOWN)).to.deep.equal({ - enabled: true, - source, - }); - } - }); - }); -}); diff --git a/test/unit/upload-encrypt.test.ts b/test/unit/upload-encrypt.test.ts index 35f1cb5..4443d8a 100644 --- a/test/unit/upload-encrypt.test.ts +++ b/test/unit/upload-encrypt.test.ts @@ -6,6 +6,7 @@ import * as path from 'node:path'; import { uploadCommand } from '../../src/commands/upload.js'; import { telemetry } from '../../src/services/telemetry.service.js'; +import { resetCliConfigCache } from '../../src/config/cli-config.js'; import { clearCompatibilityCache } from '../../src/utils/compatibility.js'; /** @@ -18,7 +19,7 @@ import { clearCompatibilityCache } from '../../src/utils/compatibility.js'; * (`{ shaPlain, encrypted: true }`), a plaintext one sends `{ sha }`. */ const API = 'http://localhost:9999'; -/** Not a devicecloud.dev API, so no pinned KEK applies. */ +/** An API that publishes no KEK on /cli-config (see the fetch stub). */ const UNKNOWN_API = 'https://api.example.com'; const APK = path.join(process.cwd(), 'test/fixtures/wikipedia.apk'); /** Printed when the server default, not a flag or env var, turned encryption on. */ @@ -42,10 +43,11 @@ describe('dcd upload encryption', () => { delete process.env.DCD_ENCRYPT; delete process.env.DCD_ENCRYPT_BINARIES; clearCompatibilityCache(); + resetCliConfigCache(); compatEncryption = undefined; compatFails = false; - // A throwaway KEK so encryption can run without a pinned key. + // A throwaway KEK so encryption can run against an API that publishes none. const { publicKey } = generateKeyPairSync('x25519'); const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(12); process.env.DCD_BINARY_KEK_PUBLIC = `1:${raw.toString('base64')}`; @@ -60,6 +62,12 @@ describe('dcd upload encryption', () => { status, }); + // The API publishes no KEK here; the tests that encrypt set the + // DCD_BINARY_KEK_PUBLIC override instead. + if (String(input).endsWith('/cli-config')) { + return json({ supabase_publishable_key: 'sb_publishable_test' }); + } + if (String(input).includes('/results/compatibility/data')) { if (compatFails) return json({ message: 'boom' }, 500); return json({ @@ -97,6 +105,7 @@ describe('dcd upload encryption', () => { telemetry.flushSync = realFlushSync; process.env = { ...ORIGINAL_ENV }; clearCompatibilityCache(); + resetCliConfigCache(); fs.rmSync(tempDir, { force: true, recursive: true }); }); @@ -186,7 +195,7 @@ describe('dcd upload encryption', () => { expect(await run({})).to.not.contain(DEFAULT_ON_NOTICE); }); - describe('against an API URL with no pinned KEK', () => { + describe('against an API that publishes no KEK', () => { beforeEach(() => { delete process.env.DCD_BINARY_KEK_PUBLIC; }); From 92a881211086ffa7e28d03de96942f543110a448 Mon Sep 17 00:00:00 2001 From: "dcd-release-please[bot]" <296541543+dcd-release-please[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:02:18 +0100 Subject: [PATCH 4/5] chore: merge production into dev after 5.9.0 (#248) * ReleaseAs: 5.8.0 * Release-As: 5.8.0 * Release-As: 5.8.0 * chore(production): release 5.8.0 * chore: pin the 5.9.0 promotion Release-As: 5.9.0 * chore(production): release 5.9.0 --------- Co-authored-by: finalerock44 Co-authored-by: finalerock44 <77282157+finalerock44@users.noreply.github.com> Co-authored-by: dcd-release-please[bot] <296541543+dcd-release-please[bot]@users.noreply.github.com> Co-authored-by: Tom Riglar Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 18 ++++++++++++++++++ package.json | 2 +- server.json | 4 ++-- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index ff20275..09242e0 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "5.8.0" + ".": "5.9.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 736884f..1377fbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [5.9.0](https://github.com/devicecloud-dev/dcd-cli/compare/v5.8.0...v5.9.0) (2026-10-22) + + +### Features + +* **auth:** fetch the Supabase key from the API instead of compiling it in ([#245](https://github.com/devicecloud-dev/dcd-cli/issues/245)) ([8cedb4e](https://github.com/devicecloud-dev/dcd-cli/commit/8cedb4e2f133f4300b669f877896e65c61c38820)) +* **login:** reuse the last org and add --org ([#243](https://github.com/devicecloud-dev/dcd-cli/issues/243)) ([5f710d5](https://github.com/devicecloud-dev/dcd-cli/commit/5f710d55d024c6869d529c606f261530ac9013ad)) + + +### Dependencies + +* bump the minor-and-patch group across 1 directory with 5 updates ([#238](https://github.com/devicecloud-dev/dcd-cli/issues/238)) ([5fffd7a](https://github.com/devicecloud-dev/dcd-cli/commit/5fffd7ae00e2e6749271ae859c6cf6c47d98678f)) + + +### Miscellaneous + +* pin the 5.9.0 promotion ([57cf6b1](https://github.com/devicecloud-dev/dcd-cli/commit/57cf6b1fc0cb74ac4fe9f30b6659095d4321fbfc)) + ## [5.8.0](https://github.com/devicecloud-dev/dcd-cli/compare/v5.7.1...v5.8.0) (2026-10-16) diff --git a/package.json b/package.json index 4db0b30..227fcaf 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "test:unit": "node scripts/test-runner.mjs --unit", "typecheck": "tsc --noEmit -p tsconfig.test.json" }, - "version": "5.8.0", + "version": "5.9.0", "bugs": { "url": "https://discord.gg/gm3mJwcNw8" }, diff --git a/server.json b/server.json index 9145abc..1856ebb 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "dev.devicecloud/dcd", "description": "Run Maestro mobile app tests on devicecloud.dev: submit flows, check status, download artifacts.", - "version": "5.8.0", + "version": "5.9.0", "repository": { "url": "https://github.com/devicecloud-dev/dcd-cli", "source": "github" @@ -11,7 +11,7 @@ { "registryType": "npm", "identifier": "@devicecloud.dev/dcd", - "version": "5.8.0", + "version": "5.9.0", "runtimeHint": "npx", "runtimeArguments": [ { From d85115c1efc1da80b7eaeb9dacca49dd0d37257b Mon Sep 17 00:00:00 2001 From: finalerock44 Date: Thu, 22 Oct 2026 12:55:41 +0100 Subject: [PATCH 5/5] chore: pin the 5.10.0 promotion Release-As: 5.10.0