diff --git a/.gitleaks.toml b/.gitleaks.toml index eb5cdaf..555ff26 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -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=''', ] diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index bedb952..37803b5 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -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, @@ -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']), ); @@ -762,6 +769,7 @@ export const cloudCommand = defineCommand({ auth, apiUrl, debug, + encrypt, filePath: finalAppFile, ignoreShaCheck, log: !json, @@ -791,6 +799,7 @@ export const cloudCommand = defineCommand({ androidApiLevel, androidDevice, androidNoSnapshot, + apiUrl, appBinaryId: finalBinaryId, cliVersion, commonRoot, @@ -798,6 +807,7 @@ export const cloudCommand = defineCommand({ debug, deviceLocale, deviceMatrix, + encrypt, env, executionPlan, flowFile, diff --git a/src/commands/upload.ts b/src/commands/upload.ts index cdb01eb..f56cb83 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -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: { @@ -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; @@ -88,6 +90,7 @@ export const uploadCommand = defineCommand({ auth, apiUrl, debug, + encrypt: encryptBinary, filePath: resolvedFile, ignoreShaCheck, log: !json, diff --git a/src/config/environments.ts b/src/config/environments.ts index 9dd7011..ccf06e5 100644 --- a/src/config/environments.ts +++ b/src/config/environments.ts @@ -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={"":""}` + * 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 = { @@ -31,6 +45,7 @@ export const ENVIRONMENTS: Record = { anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8', }, + kekPublicKey: { version: 1, key: 'wtfyWEwK7nJzwI4PD+9RAW8jxIR1u8kMQq2IhsrVnH4=' }, }, dev: { apiUrl: 'https://api.dev.devicecloud.dev', @@ -41,6 +56,7 @@ export const ENVIRONMENTS: Record = { anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ', }, + kekPublicKey: { version: 1, key: 'RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=' }, }, }; diff --git a/src/config/flags/binary.flags.ts b/src/config/flags/binary.flags.ts index c855790..6f92306 100644 --- a/src/config/flags/binary.flags.ts +++ b/src/config/flags/binary.flags.ts @@ -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; diff --git a/src/mcp/tools/run-cloud-test.ts b/src/mcp/tools/run-cloud-test.ts index 1ba76f3..d33ccdf 100644 --- a/src/mcp/tools/run-cloud-test.ts +++ b/src/mcp/tools/run-cloud-test.ts @@ -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'; @@ -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) { @@ -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, @@ -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 ?? [], diff --git a/src/methods.ts b/src/methods.ts index ebc7a0f..e2c0658 100644 --- a/src/methods.ts +++ b/src/methods.ts @@ -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 = { @@ -129,6 +137,12 @@ 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; @@ -136,6 +150,7 @@ interface UploadBinaryConfig { 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, @@ -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); @@ -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)); @@ -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=: 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 @@ -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; @@ -694,13 +769,17 @@ function validateUploadResults( * @returns Promise resolving to upload ID */ async function performUpload(config: PerformUploadConfig): Promise { - 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); diff --git a/src/services/test-submission.service.ts b/src/services/test-submission.service.ts index bbff215..678c66f 100644 --- a/src/services/test-submission.service.ts +++ b/src/services/test-submission.service.ts @@ -3,6 +3,12 @@ import * as path from 'node:path'; import { compressFilesFromRelativePath } from '../methods.js'; import { DeviceMatrixConfig } from '../types/domain/device.types.js'; +import { + type BinaryEnvelope, + encryptEnv, + encryptFlowBuffer, + resolveKekPublicKey, +} from '../utils/envelope.js'; import { toPortableRelativePath } from '../utils/paths.js'; import { IExecutionPlan } from './execution-plan.service.js'; @@ -10,6 +16,7 @@ export interface TestSubmissionConfig { androidApiLevel?: string; androidDevice?: string; androidNoSnapshot?: boolean; + apiUrl?: string; appBinaryId: string; cliVersion: string; commonRoot: string; @@ -18,6 +25,12 @@ export interface TestSubmissionConfig { deviceLocale?: string; deviceMatrix?: DeviceMatrixConfig[]; disableAnimations?: boolean; + /** + * Encrypt the flow zip and env vars before upload (#1151/#1152), each with its + * own per-upload DEK wrapped under the environment KEK. Requires `apiUrl` to + * resolve the pinned KEK public key. + */ + encrypt?: boolean; env?: string[]; executionPlan: IExecutionPlan; flowFile: string; @@ -60,7 +73,9 @@ export class TestSubmissionService { config: TestSubmissionConfig, ): Promise<{ buffer: Buffer; fields: Record; sha: string }> { const { + apiUrl, appBinaryId, + encrypt = false, flowFile, executionPlan, commonRoot, @@ -144,7 +159,7 @@ export class TestSubmissionService { this.logDebug(debug, logger, `[DEBUG] Compressing files from path: ${flowFile}`); - const buffer = await compressFilesFromRelativePath( + const plaintextZip = await compressFilesFromRelativePath( flowFile?.endsWith('.yaml') || flowFile?.endsWith('.yml') ? path.dirname(flowFile) : flowFile, @@ -158,9 +173,45 @@ export class TestSubmissionService { commonRoot, ); - this.logDebug(debug, logger, `[DEBUG] Compressed file size: ${buffer.length} bytes`); + this.logDebug(debug, logger, `[DEBUG] Compressed file size: ${plaintextZip.length} bytes`); + + // Client-side envelope encryption (#1151 flow zip, #1152 env vars). Each + // gets its own per-upload DEK wrapped under the environment KEK; the flow + // zip becomes a DCDE container and `sha` is the CIPHERTEXT hash (uploads.sha + // stays a ciphertext hash, mirroring binaries.sha). `enc` rides `fields` + // as a JSON string like every other field, so both the JSON submitFlowTest + // body and the legacy multipart form carry it identically. + let buffer = plaintextZip; + let envObjectToSend: Record = envObject; + let flowEnc: BinaryEnvelope | undefined; + if (encrypt) { + if (!apiUrl) { + throw new Error('Encryption requires apiUrl to resolve the KEK public key'); + } + const kek = resolveKekPublicKey(apiUrl); + if (!kek) { + throw new Error( + 'Encryption was requested but no KEK public key is configured for this environment. ' + + 'Set DCD_BINARY_KEK_PUBLIC=: or pin one in src/config/environments.ts.', + ); + } + const flow = encryptFlowBuffer(plaintextZip, kek); + buffer = flow.ciphertext; + flowEnc = flow.enc; + this.logDebug( + debug, + logger, + `[DEBUG] Encrypting flow zip before upload (KEK v${kek.version}); ciphertext ${buffer.length} bytes`, + ); + // Only encrypt when there are env vars; an empty map has no secret to + // protect and stays a plaintext `{}` (no `enc` marker, passes through). + if (Object.keys(envObject).length > 0) { + envObjectToSend = { enc: encryptEnv(envObject, kek) }; + this.logDebug(debug, logger, `[DEBUG] Encrypting ${Object.keys(envObject).length} env var(s)`); + } + } - // Calculate SHA-256 hash of the flow ZIP + // SHA-256 of what actually gets uploaded (ciphertext when encrypted). const sha = createHash('sha256').update(buffer).digest('hex'); this.logDebug(debug, logger, `[DEBUG] Flow ZIP SHA-256: ${sha}`); @@ -182,7 +233,12 @@ export class TestSubmissionService { fields.sequentialFlows = JSON.stringify( this.normalizePaths(sequentialFlows, commonRoot), ); - fields.env = JSON.stringify(envObject); + fields.env = JSON.stringify(envObjectToSend); + // Flow-zip envelope for uploads.metadata.enc (#1151). JSON string so both + // submission paths carry it like every other field; the API parses it. + if (flowEnc) { + fields.enc = JSON.stringify(flowEnc); + } // Note: googlePlay is now included in configPayload below instead of as a separate field // to work around a FormData parsing issue in the API diff --git a/src/types.ts b/src/types.ts index ff898d3..69f4a35 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,12 @@ +import type { BinaryEnvelope } from './utils/envelope.js'; + export type TAppMetadata = { appId: string; platform: 'android' | 'ios'; + /** + * Present when the binary was client-side envelope-encrypted before upload + * (dcd#1138). Stored on `binaries.metadata.enc`; the platform reads it to + * release the DEK and decrypt. Absent = legacy plaintext upload. + */ + enc?: BinaryEnvelope; }; diff --git a/src/utils/envelope.ts b/src/utils/envelope.ts new file mode 100644 index 0000000..747705b --- /dev/null +++ b/src/utils/envelope.ts @@ -0,0 +1,301 @@ +import { + createCipheriv, + createPublicKey, + diffieHellman, + generateKeyPairSync, + hkdfSync, + randomBytes, +} from 'node:crypto'; +import { open, stat } from 'node:fs/promises'; +import { inferEnvFromApiUrl } from '../config/environments.js'; +import { ENVIRONMENTS } from '../config/environments.js'; + +/** + * Client-side envelope encryption of app binaries before upload (dcd#1138). + * + * The CLI is the *encrypt* half of the contract; the platform (api + + * simulators, in the `dcd` repo) is the *decrypt* half. This module MUST stay + * byte-compatible with `api/src/common/crypto/envelope.ts` — the wire format is + * 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. + */ + +const MAGIC = Buffer.from('DCDE', 'ascii'); +const CONTAINER_VERSION = 1; +const NONCE_LEN = 12; +const NONCE_PREFIX_LEN = 7; +const DEK_LEN = 32; +export const CHUNK_SIZE = 1024 * 1024; // 1 MiB plaintext segments + +const HKDF_INFO = Buffer.from('dcd-binary-dek-wrap-v1', 'ascii'); +// DER prefix that turns a raw 32-byte X25519 public key into an importable SPKI. +const SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex'); + +/** `binaries.metadata.enc` / `uploads.metadata.enc` shape (binary + flow zip). */ +export interface BinaryEnvelope { + v: number; + kek: number; + wrapped_key: string; +} + +/** + * `results.env.enc` shape (#1152). Same wrapped-DEK fields as a binary, plus the + * env ciphertext carried **inline** (the env map is tiny, so a single DCDE + * segment rides in-column rather than as a separate uploaded blob). + */ +export interface EnvEnvelope extends BinaryEnvelope { + /** base64 of a single-segment DCDE container of `JSON.stringify(env)`. */ + ciphertext: string; +} + +/** + * Whether client-side envelope encryption is on. Explicit `flag` (the + * `--encrypt` CLI flag) wins; otherwise `DCD_ENCRYPT=1` enables it for binary, + * flow, and env, and the legacy `DCD_ENCRYPT_BINARIES=1` is kept as an alias. + * 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). + */ +export function isEncryptionEnabled(flag?: boolean): boolean { + if (flag !== undefined) return flag; + return ( + process.env.DCD_ENCRYPT === '1' || process.env.DCD_ENCRYPT_BINARIES === '1' + ); +} + +/** Pinned KEK public key (base64 raw 32-byte X25519) + version, per env. */ +interface KekPublicKey { + version: number; + keyRaw: Buffer; +} + +function x25519PublicFromRaw(raw: Buffer) { + return createPublicKey({ + key: Buffer.concat([SPKI_PREFIX, raw]), + format: 'der', + type: 'spki', + }); +} + +/** + * Resolve the KEK public key for the environment 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. + * 2. the pinned `ENVIRONMENTS[env].kekPublicKey`. + * Returns null when no key is available (encryption cannot proceed). + */ +export function resolveKekPublicKey(apiUrl: string): KekPublicKey | null { + const override = process.env.DCD_BINARY_KEK_PUBLIC; + if (override) { + const [versionPart, b64] = override.split(':'); + const version = Number(versionPart); + if (b64 && Number.isInteger(version)) { + const keyRaw = Buffer.from(b64, 'base64'); + if (keyRaw.length === 32) return { version, keyRaw }; + } + throw new Error( + 'DCD_BINARY_KEK_PUBLIC must be ":"', + ); + } + + const env = inferEnvFromApiUrl(apiUrl); + 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`); + } + return { version: pinned.version, keyRaw }; +} + +/** + * Wrap a DEK for the given KEK public key (X25519 sealed box). Produces base64 of + * `ephPub(32) || iv(12) || ciphertext(32) || tag(16)`. + */ +export function wrapDek(dek: Buffer, kek: KekPublicKey): BinaryEnvelope { + const eph = generateKeyPairSync('x25519'); + const ephPubRaw = eph.publicKey + .export({ type: 'spki', format: 'der' }) + .subarray(SPKI_PREFIX.length); + const shared = diffieHellman({ + privateKey: eph.privateKey, + publicKey: x25519PublicFromRaw(kek.keyRaw), + }); + const key = Buffer.from( + hkdfSync('sha256', shared, Buffer.alloc(0), HKDF_INFO, 32), + ); + const iv = randomBytes(NONCE_LEN); + const cipher = createCipheriv('aes-256-gcm', key, iv); + const ct = Buffer.concat([cipher.update(dek), cipher.final()]); + const wrapped = Buffer.concat([ + ephPubRaw, + iv, + ct, + cipher.getAuthTag(), + ]).toString('base64'); + return { v: CONTAINER_VERSION, kek: kek.version, wrapped_key: wrapped }; +} + +function segmentNonce( + prefix: Buffer, + index: number, + isLast: boolean, +): Buffer { + const nonce = Buffer.alloc(NONCE_LEN); + prefix.copy(nonce, 0, 0, NONCE_PREFIX_LEN); + nonce.writeUInt32BE(index, NONCE_PREFIX_LEN); + nonce.writeUInt8(isLast ? 1 : 0, NONCE_PREFIX_LEN + 4); + return nonce; +} + +/** + * Stream-encrypt `srcPath` into a DCDE container at `destPath` using `dek`, in + * constant memory (one chunk buffered at a time). `kekVersion` is written into + * the header (mirrors the wrapped-key's KEK version). + */ +export async function encryptFileToPath( + srcPath: string, + destPath: string, + dek: Buffer, + kekVersion: number, + chunkSize: number = CHUNK_SIZE, +): Promise { + if (dek.length !== DEK_LEN) { + throw new Error(`DEK must be ${DEK_LEN} bytes`); + } + const { size } = await stat(srcPath); + const noncePrefix = randomBytes(NONCE_PREFIX_LEN); + + const header = Buffer.alloc(17); + MAGIC.copy(header, 0); + header.writeUInt8(CONTAINER_VERSION, 4); + header.writeUInt8(kekVersion, 5); + header.writeUInt32BE(chunkSize, 6); + noncePrefix.copy(header, 10); + + const input = await open(srcPath, 'r'); + const output = await open(destPath, 'w'); + try { + await output.write(header); + const buf = Buffer.alloc(chunkSize); + let index = 0; + let readTotal = 0; + // size 0 → no segments (matches the API's empty-input handling). + while (readTotal < size) { + const { bytesRead } = await input.read(buf, 0, chunkSize, null); + if (bytesRead === 0) break; + readTotal += bytesRead; + const isLast = readTotal >= size; + const cipher = createCipheriv( + 'aes-256-gcm', + dek, + segmentNonce(noncePrefix, index, isLast), + ); + const ct = Buffer.concat([ + cipher.update(buf.subarray(0, bytesRead)), + cipher.final(), + ]); + await output.write(ct); + await output.write(cipher.getAuthTag()); + index += 1; + if (isLast) break; + } + } finally { + await input.close(); + await output.close(); + } +} + +/** + * In-memory twin of {@link encryptFileToPath}: encrypt `plaintext` into a DCDE + * container Buffer using `dek`. Byte-identical wire format (same 17-byte header, + * same per-segment nonce/tag scheme), for payloads already held in memory (flow + * zips, the env map). A payload no larger than `chunkSize` is a single segment. + */ +export function encryptToContainer( + plaintext: Buffer, + dek: Buffer, + kekVersion: number, + chunkSize: number = CHUNK_SIZE, +): Buffer { + if (dek.length !== DEK_LEN) { + throw new Error(`DEK must be ${DEK_LEN} bytes`); + } + const noncePrefix = randomBytes(NONCE_PREFIX_LEN); + + const header = Buffer.alloc(17); + MAGIC.copy(header, 0); + header.writeUInt8(CONTAINER_VERSION, 4); + header.writeUInt8(kekVersion, 5); + header.writeUInt32BE(chunkSize, 6); + noncePrefix.copy(header, 10); + + const parts: Buffer[] = [header]; + // size 0 → no segments (matches encryptFileToPath / the API's empty input). + let index = 0; + let offset = 0; + while (offset < plaintext.length) { + const end = Math.min(offset + chunkSize, plaintext.length); + const isLast = end >= plaintext.length; + const cipher = createCipheriv( + 'aes-256-gcm', + dek, + segmentNonce(noncePrefix, index, isLast), + ); + const ct = Buffer.concat([ + cipher.update(plaintext.subarray(offset, end)), + cipher.final(), + ]); + parts.push(ct, cipher.getAuthTag()); + offset = end; + index += 1; + } + return Buffer.concat(parts); +} + +/** + * Encrypt a flow zip buffer with its own per-upload DEK. Returns the ciphertext + * (a DCDE container) plus the envelope for `uploads.metadata.enc`. `uploads.sha` + * must be recomputed from the returned ciphertext by the caller. + */ +export function encryptFlowBuffer( + buffer: Buffer, + kek: KekPublicKey, +): { ciphertext: Buffer; enc: BinaryEnvelope } { + const dek = generateDek(); + const enc = wrapDek(dek, kek); + const ciphertext = encryptToContainer(buffer, dek, kek.version); + return { ciphertext, enc }; +} + +/** + * Encrypt the `--env KEY=VALUE` map with its own per-submission DEK into the + * `results.env.enc` envelope (#1152). The full map is serialized, encrypted as a + * single-segment container, and carried inline as base64 `ciphertext`. + */ +export function encryptEnv( + env: Record, + kek: KekPublicKey, +): EnvEnvelope { + const dek = generateDek(); + const { wrapped_key } = wrapDek(dek, kek); + const container = encryptToContainer( + Buffer.from(JSON.stringify(env), 'utf8'), + dek, + kek.version, + ); + return { + v: CONTAINER_VERSION, + kek: kek.version, + wrapped_key, + ciphertext: container.toString('base64'), + }; +} + +/** Generate a fresh per-upload DEK. */ +export function generateDek(): Buffer { + return randomBytes(DEK_LEN); +} diff --git a/test/unit/envelope.test.ts b/test/unit/envelope.test.ts new file mode 100644 index 0000000..2c8f1a0 --- /dev/null +++ b/test/unit/envelope.test.ts @@ -0,0 +1,241 @@ +import { expect } from 'chai'; +import { + createDecipheriv, + createPublicKey, + createPrivateKey, + diffieHellman, + generateKeyPairSync, + hkdfSync, + randomBytes, +} from 'node:crypto'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + encryptEnv, + encryptFileToPath, + encryptFlowBuffer, + generateDek, + resolveKekPublicKey, + wrapDek, +} from '../../src/utils/envelope.js'; + +/** + * Reference implementation of the platform *decrypt* half (as implemented in + * dcd `api/src/common/crypto/envelope.ts` and `simulators/gateways/ + * EnvelopeGateway.ts`). If the CLI's ciphertext round-trips through this, it is + * byte-compatible with the platform. + */ +const SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex'); +const PKCS8_PREFIX = Buffer.from('302e020100300506032b656e04220420', 'hex'); +const HKDF_INFO = Buffer.from('dcd-binary-dek-wrap-v1', 'ascii'); + +function refDecryptContainer(ciphertext: Buffer, dek: Buffer): Buffer { + expect(ciphertext.subarray(0, 4).toString('ascii')).to.equal('DCDE'); + expect(ciphertext.readUInt8(4)).to.equal(1); // container version + const chunkSize = ciphertext.readUInt32BE(6); + const noncePrefix = ciphertext.subarray(10, 17); + const body = ciphertext.subarray(17); + const segBytes = chunkSize + 16; + + const out: Buffer[] = []; + let off = 0; + let index = 0; + while (off < body.length) { + const end = Math.min(off + segBytes, body.length); + const seg = body.subarray(off, end); + const isLast = end >= body.length; + const ct = seg.subarray(0, seg.length - 16); + const tag = seg.subarray(seg.length - 16); + const nonce = Buffer.alloc(12); + noncePrefix.copy(nonce, 0, 0, 7); + nonce.writeUInt32BE(index, 7); + nonce.writeUInt8(isLast ? 1 : 0, 11); + const d = createDecipheriv('aes-256-gcm', dek, nonce); + d.setAuthTag(tag); + out.push(Buffer.concat([d.update(ct), d.final()])); + off = end; + index += 1; + } + return Buffer.concat(out); +} + +function refUnwrapDek(wrappedB64: string, kekPrivRaw: Buffer): Buffer { + const kekPriv = createPrivateKey({ + key: Buffer.concat([PKCS8_PREFIX, kekPrivRaw]), + format: 'der', + type: 'pkcs8', + }); + const blob = Buffer.from(wrappedB64, 'base64'); + const ephPubRaw = blob.subarray(0, 32); + const iv = blob.subarray(32, 44); + const ct = blob.subarray(44, 76); + const tag = blob.subarray(76, 92); + const ephPub = createPublicKey({ + key: Buffer.concat([SPKI_PREFIX, ephPubRaw]), + format: 'der', + type: 'spki', + }); + const shared = diffieHellman({ privateKey: kekPriv, publicKey: ephPub }); + const key = Buffer.from( + hkdfSync('sha256', shared, Buffer.alloc(0), HKDF_INFO, 32), + ); + const d = createDecipheriv('aes-256-gcm', key, iv); + d.setAuthTag(tag); + return Buffer.concat([d.update(ct), d.final()]); +} + +function rawX25519(): { privRaw: Buffer; pubRaw: Buffer } { + const { publicKey, privateKey } = generateKeyPairSync('x25519'); + return { + pubRaw: publicKey + .export({ type: 'spki', format: 'der' }) + .subarray(SPKI_PREFIX.length), + privRaw: privateKey + .export({ type: 'pkcs8', format: 'der' }) + .subarray(PKCS8_PREFIX.length), + }; +} + +describe('binary envelope encryption (#1138)', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(path.join(os.tmpdir(), 'dcd-enc-test-')); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + delete process.env.DCD_BINARY_KEK_PUBLIC; + }); + + const sizes: Array<[string, number]> = [ + ['sub-chunk', 500], + ['exactly one chunk', 1024], + ['one chunk + 1 byte', 1025], + ['several chunks + remainder', 4096 + 321], + ]; + + for (const [label, size] of sizes) { + it(`encrypts ${label} payloads into a DCDE container the platform decrypt recovers`, async () => { + const plaintext = randomBytes(size); + const src = path.join(dir, 'plain.bin'); + const dest = path.join(dir, 'cipher.enc'); + await writeFile(src, plaintext); + + const dek = generateDek(); + await encryptFileToPath(src, dest, dek, 1, 1024); // small chunk → multi-segment + + const ciphertext = await readFile(dest); + expect(refDecryptContainer(ciphertext, dek).equals(plaintext)).to.equal( + true, + ); + }); + } + + it('round-trips the wikipedia.apk fixture at the 1 MiB default chunk size', async () => { + const apk = await readFile( + new URL('../fixtures/wikipedia.apk', import.meta.url), + ); + const src = path.join(dir, 'wikipedia.apk'); + const dest = path.join(dir, 'wikipedia.apk.enc'); + await writeFile(src, apk); + + const dek = generateDek(); + await encryptFileToPath(src, dest, dek, 1); + + const ciphertext = await readFile(dest); + // Ciphertext must differ from plaintext and be recoverable byte-for-byte. + expect(ciphertext.subarray(0, 4).toString('ascii')).to.equal('DCDE'); + expect(refDecryptContainer(ciphertext, dek).equals(apk)).to.equal(true); + }); + + it('detects tampering (GCM tag mismatch)', async () => { + const src = path.join(dir, 'p.bin'); + const dest = path.join(dir, 'c.enc'); + await writeFile(src, randomBytes(3000)); + const dek = generateDek(); + await encryptFileToPath(src, dest, dek, 1, 1024); + const ciphertext = await readFile(dest); + ciphertext[25] ^= 0xff; // flip a ciphertext byte + expect(() => refDecryptContainer(ciphertext, dek)).to.throw(); + }); + + it('wraps a DEK that the matching KEK private key unwraps', () => { + const { privRaw, pubRaw } = rawX25519(); + process.env.DCD_BINARY_KEK_PUBLIC = `3:${pubRaw.toString('base64')}`; + const kek = resolveKekPublicKey('https://api.devicecloud.dev'); + expect(kek).to.not.equal(null); + + const dek = generateDek(); + const envelope = wrapDek(dek, kek!); + expect(envelope.v).to.equal(1); + expect(envelope.kek).to.equal(3); + expect(refUnwrapDek(envelope.wrapped_key, privRaw).equals(dek)).to.equal( + true, + ); + }); + + 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=', + ); + + 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=', + ); + }); +}); + +describe('flow + env envelope encryption (#1151, #1152)', () => { + afterEach(() => { + delete process.env.DCD_BINARY_KEK_PUBLIC; + }); + + it('encryptFlowBuffer produces a container the platform decrypts to the original zip', () => { + const { privRaw, pubRaw } = rawX25519(); + process.env.DCD_BINARY_KEK_PUBLIC = `2:${pubRaw.toString('base64')}`; + const kek = resolveKekPublicKey('https://api.dev.devicecloud.dev')!; + + const zip = randomBytes(5000); + const { ciphertext, enc } = encryptFlowBuffer(zip, kek); + + expect(enc.v).to.equal(1); + expect(enc.kek).to.equal(2); + expect(ciphertext.subarray(0, 4).toString('ascii')).to.equal('DCDE'); + const dek = refUnwrapDek(enc.wrapped_key, privRaw); + expect(refDecryptContainer(ciphertext, dek).equals(zip)).to.equal(true); + }); + + it('encryptEnv produces an inline envelope the platform decrypts back to the map', () => { + const { privRaw, pubRaw } = rawX25519(); + process.env.DCD_BINARY_KEK_PUBLIC = `1:${pubRaw.toString('base64')}`; + const kek = resolveKekPublicKey('https://api.dev.devicecloud.dev')!; + + const env = { API_TOKEN: 'secret', PASSWORD: 'p@ss word=1', EMPTY: '' }; + const enc = encryptEnv(env, kek); + + expect(enc.v).to.equal(1); + expect(enc.kek).to.equal(1); + // API unwraps the DEK from wrapped_key; runner decrypts the inline blob. + const dek = refUnwrapDek(enc.wrapped_key, privRaw); + const plain = refDecryptContainer(Buffer.from(enc.ciphertext, 'base64'), dek); + expect(JSON.parse(plain.toString('utf8'))).to.deep.equal(env); + }); + + it('gives the flow zip and env their own distinct DEKs', () => { + const { pubRaw } = rawX25519(); + process.env.DCD_BINARY_KEK_PUBLIC = `1:${pubRaw.toString('base64')}`; + const kek = resolveKekPublicKey('https://api.dev.devicecloud.dev')!; + + const flow = encryptFlowBuffer(randomBytes(100), kek); + const env = encryptEnv({ A: 'b' }, kek); + // Independent sealed boxes → the wrapped keys must differ. + expect(flow.enc.wrapped_key).to.not.equal(env.wrapped_key); + }); +});