Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/commands/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export const uploadCommand = defineCommand({
}
}

const encryption = checkKekForEncryption(
const encryption = await checkKekForEncryption(
resolveEncryption({ flag: encryptFlag, serverDefault }),
apiUrl,
);
Expand Down
61 changes: 61 additions & 0 deletions src/config/cli-config.ts
Original file line number Diff line number Diff line change
@@ -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<string, Promise<CliConfig>>();

/** `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<CliConfig> {
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<CliConfig> {
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();
}
46 changes: 5 additions & 41 deletions src/config/environments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<DcdEnvName, DcdEnvironment> = {
Expand All @@ -41,7 +28,6 @@ export const ENVIRONMENTS: Record<DcdEnvName, DcdEnvironment> = {
url: 'https://cloud.devicecloud.dev',
projectRef: 'pgydnphbimetinsgfkbo',
},
kekPublicKey: { version: 1, key: 'wtfyWEwK7nJzwI4PD+9RAW8jxIR1u8kMQq2IhsrVnH4=' },
},
dev: {
apiUrl: 'https://api.dev.devicecloud.dev',
Expand All @@ -50,7 +36,6 @@ export const ENVIRONMENTS: Record<DcdEnvName, DcdEnvironment> = {
url: 'https://lbmsowehtjwnqlurpemb.supabase.co',
projectRef: 'lbmsowehtjwnqlurpemb',
},
kekPublicKey: { version: 1, key: 'RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=' },
},
};

Expand All @@ -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
Expand Down
40 changes: 9 additions & 31 deletions src/config/supabase-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DcdEnvName, Promise<string>>();

async function fetchSupabaseKey(env: DcdEnvName): Promise<string> {
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<string> {
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<string> {
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
Expand All @@ -56,5 +34,5 @@ export function supabaseKeyHeaders(key: string): Record<string, string> {

/** Test seam: forget fetched keys. */
export function resetSupabaseKeyCache(): void {
cache.clear();
resetCliConfigCache();
}
2 changes: 1 addition & 1 deletion src/mcp/tools/run-cloud-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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. ' +
Expand Down
2 changes: 1 addition & 1 deletion src/services/test-submission.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. ' +
Expand Down
61 changes: 34 additions & 27 deletions src/utils/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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');
Expand Down Expand Up @@ -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<EncryptionDecision> {
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=<version>:<base64> 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;
Expand All @@ -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 (`<version>:<base64>`, 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<KekPublicKey | null> {
const override = process.env.DCD_BINARY_KEK_PUBLIC;
if (override) {
const [versionPart, b64] = override.split(':');
Expand All @@ -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 };
}

/**
Expand Down
2 changes: 1 addition & 1 deletion test/unit/encrypted-dedup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading