Skip to content
Open
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
29 changes: 21 additions & 8 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -1,20 +1,33 @@
# Gitleaks configuration — extends the default ruleset.
#
# The only allowlisted secrets are the two PUBLIC Supabase anon keys committed
# in src/config/environments.ts. Those JWTs are anon-role keys, designed to be
# embedded in client code and gated by RLS (see the doc comment in that file) —
# they are intentionally not secret.
# The only allowlisted secrets are PUBLIC key material committed in
# src/config/environments.ts:
# - the two Supabase anon keys — anon-role JWTs, designed to be embedded in
# client code and gated by RLS (see the doc comment in that file).
# - the two KEK public keys (prod + dev) for client-side binary envelope
# encryption — base64 of the raw 32-byte X25519 PUBLIC half. They can only
# *encrypt*; the private halves live solely on the API, never in this repo.
# Both are intentionally not secret; gitleaks flags them on entropy alone
# (generic-api-key), not because it recognizes them as credentials.
#
# They are allowlisted by EXACT VALUE, deliberately not by file path or by the
# whole `jwt` rule: a Supabase service_role key is also a JWT, so a path/rule
# allowlist would let a genuinely sensitive key pasted into the same file slip
# through. Matching exact values keeps that detection intact.
# whole `jwt` / `generic-api-key` rule: a Supabase service_role key is also a
# JWT, and a KEK *private* key is the same shape as its public half, so a
# path/rule allowlist would let a genuinely sensitive key pasted into the same
# file slip through. Matching exact values keeps that detection intact.
#
# When a KEK is rotated, replace the corresponding value below — do not simply
# append, or the retired key stops being distinguishable from a live secret.
[extend]
useDefault = true

[allowlist]
description = "Public Supabase anon keys (safe to commit, gated by RLS)"
description = "Public Supabase anon keys and KEK public keys (safe to commit)"
regexes = [
'''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0\.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8''',
'''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30\.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ''',
# prod kekPublicKey v1
'''wtfyWEwK7nJzwI4PD\+9RAW8jxIR1u8kMQq2IhsrVnH4=''',
# dev kekPublicKey v1
'''RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=''',
]
10 changes: 10 additions & 0 deletions src/commands/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
matrixIsIos,
parseDeviceMatrix,
} from '../utils/device-matrix.js';
import { isEncryptionEnabled } from '../utils/envelope.js';
import { detectCiContext, isCI } from '../utils/ci.js';
import {
CliError,
Expand Down Expand Up @@ -194,6 +195,12 @@ export const cloudCommand = defineCommand({
let flows = args.flows as string | undefined;
const googlePlay = Boolean(args['google-play']);
const ignoreShaCheck = Boolean(args['ignore-sha-check']);
// Single opt-in for client-side envelope encryption of every sensitive
// artifact — the binary (#1138), the flow zip (#1151), and env vars
// (#1152). Flag wins; otherwise DCD_ENCRYPT / DCD_ENCRYPT_BINARIES.
const encrypt = isEncryptionEnabled(
args['encrypt'] ? true : undefined,
);
const includeTags = coerceArray(
collectRepeatedFlag(rawArgs, ['--include-tags']),
);
Expand Down Expand Up @@ -762,6 +769,7 @@ export const cloudCommand = defineCommand({
auth,
apiUrl,
debug,
encrypt,
filePath: finalAppFile,
ignoreShaCheck,
log: !json,
Expand Down Expand Up @@ -791,13 +799,15 @@ export const cloudCommand = defineCommand({
androidApiLevel,
androidDevice,
androidNoSnapshot,
apiUrl,
appBinaryId: finalBinaryId,
cliVersion,
commonRoot,
continueOnFailure,
debug,
deviceLocale,
deviceMatrix,
encrypt,
env,
executionPlan,
flowFile,
Expand Down
3 changes: 3 additions & 0 deletions src/commands/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const uploadCommand = defineCommand({
...apiFlags,
'app-url': binaryFlags['app-url'],
'ignore-sha-check': binaryFlags['ignore-sha-check'],
encrypt: binaryFlags.encrypt,
debug: outputFlags.debug,
json: outputFlags.json,
appFile: {
Expand All @@ -42,6 +43,7 @@ export const uploadCommand = defineCommand({
const apiUrl = resolveApiUrl(args['api-url'] as string | undefined);
const appUrl = args['app-url'] as string | undefined;
const ignoreShaCheck = Boolean(args['ignore-sha-check']);
const encryptBinary = Boolean(args['encrypt']);
const debug = Boolean(args.debug);
const positional = args.appFile as string | undefined;

Expand Down Expand Up @@ -88,6 +90,7 @@ export const uploadCommand = defineCommand({
auth,
apiUrl,
debug,
encrypt: encryptBinary,
filePath: resolvedFile,
ignoreShaCheck,
log: !json,
Expand Down
16 changes: 16 additions & 0 deletions src/config/environments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ export interface DcdEnvironment {
projectRef: string;
anonKey: 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** (like the anon
* key above): 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.:
* 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={"<version>":"<private base64>"}`
* 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).
*/
kekPublicKey: { version: number; key: string } | null;
}

export const ENVIRONMENTS: Record<DcdEnvName, DcdEnvironment> = {
Expand All @@ -31,6 +45,7 @@ export const ENVIRONMENTS: Record<DcdEnvName, DcdEnvironment> = {
anonKey:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8',
},
kekPublicKey: { version: 1, key: 'wtfyWEwK7nJzwI4PD+9RAW8jxIR1u8kMQq2IhsrVnH4=' },
},
dev: {
apiUrl: 'https://api.dev.devicecloud.dev',
Expand All @@ -41,6 +56,7 @@ export const ENVIRONMENTS: Record<DcdEnvName, DcdEnvironment> = {
anonKey:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ',
},
kekPublicKey: { version: 1, key: 'RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=' },
},
};

Expand Down
5 changes: 5 additions & 0 deletions src/config/flags/binary.flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,9 @@ export const binaryFlags = {
description:
'Ignore the sha hash check and upload the binary regardless of whether it already exists (not recommended)',
},
encrypt: {
type: 'boolean',
description:
'Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). Can also be enabled with DCD_ENCRYPT=1.',
},
} as const satisfies ArgsDef;
8 changes: 8 additions & 0 deletions src/mcp/tools/run-cloud-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { VersionService } from '../../services/version.service.js';
import { uploadBinary, uploadFlowZip, verifyAppZip } from '../../methods.js';
import { getCliVersion } from '../../utils/cli.js';
import { fetchCompatibilityData } from '../../utils/compatibility.js';
import { isEncryptionEnabled } from '../../utils/envelope.js';
import { getConsoleUrl } from '../../utils/styling.js';
import { getContext, logStderr } from '../context.js';
import { jsonResult, runTool } from '../helpers.js';
Expand Down Expand Up @@ -173,6 +174,10 @@ export function registerRunCloudTest(server: McpServer): void {
});
}

// Client-side envelope encryption (binary/flow/env). No MCP flag, so
// it's env-driven: DCD_ENCRYPT / DCD_ENCRYPT_BINARIES.
const encrypt = isEncryptionEnabled();

// Resolve the binary: existing id, or upload the local file.
let appBinaryId = args.appBinaryId;
if (!appBinaryId) {
Expand All @@ -190,6 +195,7 @@ export function registerRunCloudTest(server: McpServer): void {
appBinaryId = await uploadBinary({
auth,
apiUrl,
encrypt,
filePath: args.appFile,
ignoreShaCheck: Boolean(args.ignoreShaCheck),
log: false,
Expand All @@ -199,10 +205,12 @@ export function registerRunCloudTest(server: McpServer): void {
const { continueOnFailure = true } = executionPlan.sequence ?? {};
const testSubmissionService = new TestSubmissionService();
const { buffer, fields } = await testSubmissionService.buildTestPayload({
apiUrl,
appBinaryId,
cliVersion,
commonRoot,
continueOnFailure,
encrypt,
executionPlan,
flowFile,
env: args.env ?? [],
Expand Down
85 changes: 82 additions & 3 deletions src/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ import { SupabaseGateway } from './gateways/supabase-gateway.js';
import { MetadataExtractorService } from './services/metadata-extractor.service.js';
import { TAppMetadata } from './types.js';
import type { AuthContext } from './types/domain/auth.types.js';
import {
type BinaryEnvelope,
encryptFileToPath,
generateDek,
isEncryptionEnabled,
resolveKekPublicKey,
wrapDek,
} from './utils/envelope.js';
import { colors, formatId } from './utils/styling.js';

const mimeTypeLookupByExtension: Record<string, string> = {
Expand Down Expand Up @@ -129,13 +137,20 @@ interface UploadBinaryConfig {
auth: AuthContext;
apiUrl: string;
debug?: boolean;
/**
* Encrypt the binary before upload (client-side envelope encryption, #1138).
* Defaults to `DCD_ENCRYPT` / `DCD_ENCRYPT_BINARIES` when unset (see
* {@link isEncryptionEnabled}).
*/
encrypt?: boolean;
filePath: string;
ignoreShaCheck?: boolean;
log?: boolean;
}

export const uploadBinary = async (config: UploadBinaryConfig) => {
const { filePath, apiUrl, auth, ignoreShaCheck = false, log = true, debug = false } = config;
const encrypt = isEncryptionEnabled(config.encrypt);
if (log) {
ux.action.start(colors.bold('Checking and uploading binary'), colors.dim('Initializing'), {
stdout: true,
Expand All @@ -151,11 +166,24 @@ export const uploadBinary = async (config: UploadBinaryConfig) => {

const startTime = Date.now();
let source: UploadSource | undefined;
let encCleanupDir: string | undefined;
let enc: BinaryEnvelope | undefined;

try {
// Prepare file for upload
source = await prepareFileForUpload(filePath, debug, startTime);

// Encrypt before hashing/upload so the SHA, dedup check, and both uploaders
// all operate on ciphertext (binaries.sha = ciphertext hash, per #1138).
if (encrypt) {
const encrypted = await encryptUploadSource(source, apiUrl, debug);
enc = encrypted.enc;
encCleanupDir = encrypted.cleanupDir;
if (log) {
ux.info(colors.dim(`Encrypting binary before upload (KEK v${enc.kek})`));
}
}

// Calculate SHA hash
const sha = await calculateFileHash(source, debug, log);

Expand All @@ -176,7 +204,7 @@ export const uploadBinary = async (config: UploadBinaryConfig) => {
}

// Perform the upload
const uploadId = await performUpload({ auth, apiUrl, debug, filePath, sha, source, startTime });
const uploadId = await performUpload({ auth, apiUrl, debug, enc, filePath, sha, source, startTime });

if (log) {
ux.action.stop(colors.success('\n✓ Binary uploaded with ID: ') + formatId(uploadId));
Expand Down Expand Up @@ -205,9 +233,54 @@ export const uploadBinary = async (config: UploadBinaryConfig) => {
if (source?.cleanupDir) {
await rm(source.cleanupDir, { recursive: true, force: true }).catch(() => {});
}
if (encCleanupDir) {
await rm(encCleanupDir, { recursive: true, force: true }).catch(() => {});
}
}
};

/**
* 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
* 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.
*/
async function encryptUploadSource(
source: UploadSource,
apiUrl: string,
debug: boolean,
): Promise<{ enc: BinaryEnvelope; cleanupDir: string }> {
const kek = resolveKekPublicKey(apiUrl);
if (!kek) {
throw new Error(
'Binary encryption was requested but no KEK public key is configured for this environment. ' +
'Set DCD_BINARY_KEK_PUBLIC=<version>:<base64> or pin one in src/config/environments.ts.',
);
}

const dek = generateDek();
const enc = wrapDek(dek, kek);
const cleanupDir = await mkdtemp(path.join(os.tmpdir(), 'dcd-enc-'));
const cipherPath = path.join(cleanupDir, 'binary.enc');

if (debug) {
console.log(`[DEBUG] Encrypting binary with KEK v${kek.version} -> ${cipherPath}`);
}

await encryptFileToPath(source.diskPath, cipherPath, dek, kek.version);
const { size } = await stat(cipherPath);
source.diskPath = cipherPath;
source.size = size;

if (debug) {
console.log(`[DEBUG] Ciphertext size: ${(size / 1024 / 1024).toFixed(2)} MB`);
}

return { enc, cleanupDir };
}

/**
* Disk-backed description of the binary to upload. Every upload path streams
* from `diskPath` instead of materializing the file in memory — a 1.5 GB iOS
Expand Down Expand Up @@ -416,6 +489,8 @@ interface PerformUploadConfig {
auth: AuthContext;
apiUrl: string;
debug: boolean;
/** Envelope metadata when the binary was encrypted (#1138); undefined otherwise. */
enc?: BinaryEnvelope;
filePath: string;
sha: string | undefined;
source: UploadSource;
Expand Down Expand Up @@ -694,13 +769,17 @@ function validateUploadResults(
* @returns Promise resolving to upload ID
*/
async function performUpload(config: PerformUploadConfig): Promise<string> {
const { filePath, apiUrl, auth, source, sha, debug, startTime } = config;
const { filePath, apiUrl, auth, enc, source, sha, debug, startTime } = config;

// Request upload URL and paths
const { id, tempPath, finalPath, b2 } = await requestUploadPaths(apiUrl, auth, filePath, source.size, debug);

// Extract app metadata
// Extract app metadata from the original (plaintext) file. Attach the
// envelope so it lands on binaries.metadata.enc (#1138).
const metadata = await extractBinaryMetadata(filePath, debug);
if (enc) {
metadata.enc = enc;
}

const env = inferEnvFromApiUrl(apiUrl);

Expand Down
Loading
Loading