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; });