From 18c1e701de4ef5dc67655b2d99227176a47382f8 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 24 Jul 2026 11:54:33 +0100 Subject: [PATCH 1/4] feat: client-side envelope encryption of app binaries before upload (#1138) Encrypt half of the dcd#1138 contract (the platform api + simulators are the decrypt half). Opt-in via `--encrypt` or `DCD_ENCRYPT_BINARIES=1`; off by default, so uploads are unchanged unless requested. - src/utils/envelope.ts: per-upload DEK, chunked AES-256-GCM container (streamed, constant memory), X25519 sealed-box DEK wrap. Byte-compatible with api/src/common/crypto/envelope.ts (format in dcd/docs/binary-envelope-encryption.md). - src/config/environments.ts: pinned per-env KEK public key slot (null until provisioned; DCD_BINARY_KEK_PUBLIC override for testing). - src/methods.ts: encrypt source in place before hashing/upload so the SHA, dedup check, and both uploaders operate on ciphertext (binaries.sha = ciphertext hash); attach the envelope to binaries.metadata.enc at finalise. - src/types.ts: TAppMetadata.enc; binary.flags.ts: --encrypt; cloud/upload thread the flag. - test/unit/envelope.test.ts: round-trips (incl. the 15MB wikipedia.apk fixture) through a decrypt mirroring the platform, tamper detection, and DEK wrap/unwrap. Verified end-to-end: CLI-encrypted output + wrapped DEK decrypt byte-for-byte with the real dcd api envelope code. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/cloud.ts | 2 + src/commands/upload.ts | 3 + src/config/environments.ts | 16 +++ src/config/flags/binary.flags.ts | 5 + src/methods.ts | 83 +++++++++++++- src/types.ts | 8 ++ src/utils/envelope.ts | 191 +++++++++++++++++++++++++++++++ test/unit/envelope.test.ts | 182 +++++++++++++++++++++++++++++ 8 files changed, 487 insertions(+), 3 deletions(-) create mode 100644 src/utils/envelope.ts create mode 100644 test/unit/envelope.test.ts diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index bedb952..b1b384a 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -194,6 +194,7 @@ export const cloudCommand = defineCommand({ let flows = args.flows as string | undefined; const googlePlay = Boolean(args['google-play']); const ignoreShaCheck = Boolean(args['ignore-sha-check']); + const encryptBinary = Boolean(args['encrypt']); const includeTags = coerceArray( collectRepeatedFlag(rawArgs, ['--include-tags']), ); @@ -762,6 +763,7 @@ export const cloudCommand = defineCommand({ auth, apiUrl, debug, + encrypt: encryptBinary, filePath: finalAppFile, ignoreShaCheck, log: !json, 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..09c31c0 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: null, }, dev: { apiUrl: 'https://api.dev.devicecloud.dev', @@ -41,6 +56,7 @@ export const ENVIRONMENTS: Record = { anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ', }, + kekPublicKey: null, }, }; diff --git a/src/config/flags/binary.flags.ts b/src/config/flags/binary.flags.ts index c855790..993578a 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 before upload (client-side envelope encryption). Can also be enabled with DCD_ENCRYPT_BINARIES=1.', + }, } as const satisfies ArgsDef; diff --git a/src/methods.ts b/src/methods.ts index ebc7a0f..aaffe07 100644 --- a/src/methods.ts +++ b/src/methods.ts @@ -20,6 +20,13 @@ 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, + resolveKekPublicKey, + wrapDek, +} from './utils/envelope.js'; import { colors, formatId } from './utils/styling.js'; const mimeTypeLookupByExtension: Record = { @@ -129,6 +136,11 @@ interface UploadBinaryConfig { auth: AuthContext; apiUrl: string; debug?: boolean; + /** + * Encrypt the binary before upload (client-side envelope encryption, #1138). + * Defaults to the `DCD_ENCRYPT_BINARIES=1` env var when unset. + */ + encrypt?: boolean; filePath: string; ignoreShaCheck?: boolean; log?: boolean; @@ -136,6 +148,7 @@ interface UploadBinaryConfig { export const uploadBinary = async (config: UploadBinaryConfig) => { const { filePath, apiUrl, auth, ignoreShaCheck = false, log = true, debug = false } = config; + const encrypt = config.encrypt ?? process.env.DCD_ENCRYPT_BINARIES === '1'; if (log) { ux.action.start(colors.bold('Checking and uploading binary'), colors.dim('Initializing'), { stdout: true, @@ -151,11 +164,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 +202,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 +231,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 +487,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 +767,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/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..0e6bf13 --- /dev/null +++ b/src/utils/envelope.ts @@ -0,0 +1,191 @@ +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` shape written for an encrypted upload. */ +export interface BinaryEnvelope { + v: number; + kek: number; + wrapped_key: string; +} + +/** 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(); + } +} + +/** 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..8b91104 --- /dev/null +++ b/test/unit/envelope.test.ts @@ -0,0 +1,182 @@ +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 { + encryptFileToPath, + 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('returns null when no KEK is pinned or overridden', () => { + expect(resolveKekPublicKey('https://api.devicecloud.dev')).to.equal(null); + expect(resolveKekPublicKey('https://api.dev.devicecloud.dev')).to.equal( + null, + ); + }); +}); From bfa2b4c7f8bd06314e24f9f65df14c39869788c6 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 24 Jul 2026 14:44:51 +0100 Subject: [PATCH 2/4] chore: pin dev and production KEK public keys for binary encryption Fill in the previously-null kekPublicKey slots (version 1) now that the key-encryption keypairs are provisioned. These are X25519 *public* keys and are safe to embed in a public release, exactly like the Supabase anon keys already checked in here; the matching private halves live only on the platform API and are never shipped. Update the resolver test to assert each environment resolves to its pinned version and key rather than null. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/config/environments.ts | 4 ++-- test/unit/envelope.test.ts | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/config/environments.ts b/src/config/environments.ts index 09c31c0..ccf06e5 100644 --- a/src/config/environments.ts +++ b/src/config/environments.ts @@ -45,7 +45,7 @@ export const ENVIRONMENTS: Record = { anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8', }, - kekPublicKey: null, + kekPublicKey: { version: 1, key: 'wtfyWEwK7nJzwI4PD+9RAW8jxIR1u8kMQq2IhsrVnH4=' }, }, dev: { apiUrl: 'https://api.dev.devicecloud.dev', @@ -56,7 +56,7 @@ export const ENVIRONMENTS: Record = { anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ', }, - kekPublicKey: null, + kekPublicKey: { version: 1, key: 'RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=' }, }, }; diff --git a/test/unit/envelope.test.ts b/test/unit/envelope.test.ts index 8b91104..5854e1b 100644 --- a/test/unit/envelope.test.ts +++ b/test/unit/envelope.test.ts @@ -173,10 +173,19 @@ describe('binary envelope encryption (#1138)', () => { ); }); - it('returns null when no KEK is pinned or overridden', () => { - expect(resolveKekPublicKey('https://api.devicecloud.dev')).to.equal(null); - expect(resolveKekPublicKey('https://api.dev.devicecloud.dev')).to.equal( - null, + 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=', ); }); }); From 3385cf8b3447beb5fa80cf73a3c5bb9da6d7feae Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Fri, 24 Jul 2026 15:14:39 +0100 Subject: [PATCH 3/4] feat: client-side encryption of flow zips and env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the binary envelope scheme to the two remaining sensitive client-side inputs — the Maestro flow zip and the injected --env KEY=VALUE secrets — so the platform stores only ciphertext at rest. Enabling encryption now covers the binary, the flow zip, and the env vars, each with its OWN per-upload DEK (all wrapped under the same pinned per-environment KEK public key). - utils/envelope.ts: encryptToContainer (in-memory DCDE twin of the streaming file encryptor), encryptFlowBuffer, and encryptEnv (a single-segment inline blob for results.env.enc), plus a shared isEncryptionEnabled(). Byte-compatible with the platform decrypt half. - test-submission.service.ts is the single seam: when encrypting, the flow zip becomes a DCDE container (the sha sent to the API is the CIPHERTEXT hash), the flow envelope rides fields.enc as a JSON string (so both the JSON and legacy multipart submission paths carry it identically), and the env map becomes an { enc } envelope. Both the cloud command and the MCP tool get this for free via buildTestPayload. - --encrypt (and DCD_ENCRYPT=1) now gate all three; DCD_ENCRYPT_BINARIES=1 stays as an alias for the binary-only behaviour. - Round-trips flow + env ciphertext through a mirror of the platform decrypt. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/cloud.ts | 12 ++- src/config/flags/binary.flags.ts | 2 +- src/mcp/tools/run-cloud-test.ts | 8 ++ src/methods.ts | 6 +- src/services/test-submission.service.ts | 64 +++++++++++++- src/utils/envelope.ts | 112 +++++++++++++++++++++++- test/unit/envelope.test.ts | 50 +++++++++++ 7 files changed, 244 insertions(+), 10 deletions(-) diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index b1b384a..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,7 +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']); - const encryptBinary = Boolean(args['encrypt']); + // 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']), ); @@ -763,7 +769,7 @@ export const cloudCommand = defineCommand({ auth, apiUrl, debug, - encrypt: encryptBinary, + encrypt, filePath: finalAppFile, ignoreShaCheck, log: !json, @@ -793,6 +799,7 @@ export const cloudCommand = defineCommand({ androidApiLevel, androidDevice, androidNoSnapshot, + apiUrl, appBinaryId: finalBinaryId, cliVersion, commonRoot, @@ -800,6 +807,7 @@ export const cloudCommand = defineCommand({ debug, deviceLocale, deviceMatrix, + encrypt, env, executionPlan, flowFile, diff --git a/src/config/flags/binary.flags.ts b/src/config/flags/binary.flags.ts index 993578a..6f92306 100644 --- a/src/config/flags/binary.flags.ts +++ b/src/config/flags/binary.flags.ts @@ -27,6 +27,6 @@ export const binaryFlags = { encrypt: { type: 'boolean', description: - 'Encrypt the app binary before upload (client-side envelope encryption). Can also be enabled with DCD_ENCRYPT_BINARIES=1.', + '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 aaffe07..e2c0658 100644 --- a/src/methods.ts +++ b/src/methods.ts @@ -24,6 +24,7 @@ import { type BinaryEnvelope, encryptFileToPath, generateDek, + isEncryptionEnabled, resolveKekPublicKey, wrapDek, } from './utils/envelope.js'; @@ -138,7 +139,8 @@ interface UploadBinaryConfig { debug?: boolean; /** * Encrypt the binary before upload (client-side envelope encryption, #1138). - * Defaults to the `DCD_ENCRYPT_BINARIES=1` env var when unset. + * Defaults to `DCD_ENCRYPT` / `DCD_ENCRYPT_BINARIES` when unset (see + * {@link isEncryptionEnabled}). */ encrypt?: boolean; filePath: string; @@ -148,7 +150,7 @@ interface UploadBinaryConfig { export const uploadBinary = async (config: UploadBinaryConfig) => { const { filePath, apiUrl, auth, ignoreShaCheck = false, log = true, debug = false } = config; - const encrypt = config.encrypt ?? process.env.DCD_ENCRYPT_BINARIES === '1'; + const encrypt = isEncryptionEnabled(config.encrypt); if (log) { ux.action.start(colors.bold('Checking and uploading binary'), colors.dim('Initializing'), { stdout: true, 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/utils/envelope.ts b/src/utils/envelope.ts index 0e6bf13..747705b 100644 --- a/src/utils/envelope.ts +++ b/src/utils/envelope.ts @@ -35,13 +35,37 @@ 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` shape written for an encrypted upload. */ +/** `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; @@ -185,6 +209,92 @@ export async function encryptFileToPath( } } +/** + * 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 index 5854e1b..2c8f1a0 100644 --- a/test/unit/envelope.test.ts +++ b/test/unit/envelope.test.ts @@ -12,7 +12,9 @@ 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, @@ -189,3 +191,51 @@ describe('binary envelope encryption (#1138)', () => { ); }); }); + +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); + }); +}); From aa30ac49b47099f4666f0315f1d4d39cb08025e4 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Sat, 25 Jul 2026 11:21:16 +0100 Subject: [PATCH 4/4] chore: allowlist the pinned KEK public keys in gitleaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two KEK public keys pinned in bfa2b4c tripped gitleaks' default generic-api-key rule on entropy alone (5.02 / 4.89), failing the secret-scan job. They are base64 of the raw 32-byte X25519 *public* halves — encrypt-only, with the private halves living solely in API env config, never in this repo. Allowlisted by exact value, matching the convention already used for the Supabase anon keys and deliberately not by file path or by the whole rule: a KEK private key is byte-identical in shape to its public half, so a path allowlist would blind the scanner to a genuine leak in precisely the file most likely to contain one. Co-Authored-By: Claude Opus 5 (1M context) --- .gitleaks.toml | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) 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=''', ]