Skip to content

Commit a34d9d4

Browse files
riglarclaude
andauthored
feat: client-side envelope encryption of binaries, flow zips & env vars (#94)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * feat: client-side encryption of flow zips and env vars 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) <noreply@anthropic.com> * chore: allowlist the pinned KEK public keys in gitleaks 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 47b9d90 commit a34d9d4

11 files changed

Lines changed: 755 additions & 15 deletions

File tree

.gitleaks.toml

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

1524
[allowlist]
16-
description = "Public Supabase anon keys (safe to commit, gated by RLS)"
25+
description = "Public Supabase anon keys and KEK public keys (safe to commit)"
1726
regexes = [
1827
'''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0\.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8''',
1928
'''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30\.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ''',
29+
# prod kekPublicKey v1
30+
'''wtfyWEwK7nJzwI4PD\+9RAW8jxIR1u8kMQq2IhsrVnH4=''',
31+
# dev kekPublicKey v1
32+
'''RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=''',
2033
]

src/commands/cloud.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
matrixIsIos,
4141
parseDeviceMatrix,
4242
} from '../utils/device-matrix.js';
43+
import { isEncryptionEnabled } from '../utils/envelope.js';
4344
import { detectCiContext, isCI } from '../utils/ci.js';
4445
import {
4546
CliError,
@@ -194,6 +195,12 @@ export const cloudCommand = defineCommand({
194195
let flows = args.flows as string | undefined;
195196
const googlePlay = Boolean(args['google-play']);
196197
const ignoreShaCheck = Boolean(args['ignore-sha-check']);
198+
// Single opt-in for client-side envelope encryption of every sensitive
199+
// artifact — the binary (#1138), the flow zip (#1151), and env vars
200+
// (#1152). Flag wins; otherwise DCD_ENCRYPT / DCD_ENCRYPT_BINARIES.
201+
const encrypt = isEncryptionEnabled(
202+
args['encrypt'] ? true : undefined,
203+
);
197204
const includeTags = coerceArray(
198205
collectRepeatedFlag(rawArgs, ['--include-tags']),
199206
);
@@ -762,6 +769,7 @@ export const cloudCommand = defineCommand({
762769
auth,
763770
apiUrl,
764771
debug,
772+
encrypt,
765773
filePath: finalAppFile,
766774
ignoreShaCheck,
767775
log: !json,
@@ -791,13 +799,15 @@ export const cloudCommand = defineCommand({
791799
androidApiLevel,
792800
androidDevice,
793801
androidNoSnapshot,
802+
apiUrl,
794803
appBinaryId: finalBinaryId,
795804
cliVersion,
796805
commonRoot,
797806
continueOnFailure,
798807
debug,
799808
deviceLocale,
800809
deviceMatrix,
810+
encrypt,
801811
env,
802812
executionPlan,
803813
flowFile,

src/commands/upload.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const uploadCommand = defineCommand({
2121
...apiFlags,
2222
'app-url': binaryFlags['app-url'],
2323
'ignore-sha-check': binaryFlags['ignore-sha-check'],
24+
encrypt: binaryFlags.encrypt,
2425
debug: outputFlags.debug,
2526
json: outputFlags.json,
2627
appFile: {
@@ -42,6 +43,7 @@ export const uploadCommand = defineCommand({
4243
const apiUrl = resolveApiUrl(args['api-url'] as string | undefined);
4344
const appUrl = args['app-url'] as string | undefined;
4445
const ignoreShaCheck = Boolean(args['ignore-sha-check']);
46+
const encryptBinary = Boolean(args['encrypt']);
4547
const debug = Boolean(args.debug);
4648
const positional = args.appFile as string | undefined;
4749

@@ -88,6 +90,7 @@ export const uploadCommand = defineCommand({
8890
auth,
8991
apiUrl,
9092
debug,
93+
encrypt: encryptBinary,
9194
filePath: resolvedFile,
9295
ignoreShaCheck,
9396
log: !json,

src/config/environments.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@ export interface DcdEnvironment {
1919
projectRef: string;
2020
anonKey: string;
2121
};
22+
/**
23+
* Pinned KEK public key for client-side binary envelope encryption (dcd#1138).
24+
* `key` is base64 of the raw 32-byte X25519 public key; `version` selects which
25+
* KEK the platform API unwraps with. **Public — safe to embed** (like the anon
26+
* key above): it can only *encrypt*; the private half lives solely on the API.
27+
*
28+
* Left `null` until the KEK is generated and provisioned. Generate a keypair
29+
* with, e.g.:
30+
* 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"))'
31+
* Pin `public` here; set `private` as `BINARY_KEK_PRIVATE_KEYS={"<version>":"<private base64>"}`
32+
* on the API service and keep one offline escrow copy. Until then, encryption
33+
* can be exercised via the `DCD_BINARY_KEK_PUBLIC` env override (see envelope.ts).
34+
*/
35+
kekPublicKey: { version: number; key: string } | null;
2236
}
2337

2438
export const ENVIRONMENTS: Record<DcdEnvName, DcdEnvironment> = {
@@ -31,6 +45,7 @@ export const ENVIRONMENTS: Record<DcdEnvName, DcdEnvironment> = {
3145
anonKey:
3246
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8',
3347
},
48+
kekPublicKey: { version: 1, key: 'wtfyWEwK7nJzwI4PD+9RAW8jxIR1u8kMQq2IhsrVnH4=' },
3449
},
3550
dev: {
3651
apiUrl: 'https://api.dev.devicecloud.dev',
@@ -41,6 +56,7 @@ export const ENVIRONMENTS: Record<DcdEnvName, DcdEnvironment> = {
4156
anonKey:
4257
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ',
4358
},
59+
kekPublicKey: { version: 1, key: 'RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=' },
4460
},
4561
};
4662

src/config/flags/binary.flags.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,9 @@ export const binaryFlags = {
2424
description:
2525
'Ignore the sha hash check and upload the binary regardless of whether it already exists (not recommended)',
2626
},
27+
encrypt: {
28+
type: 'boolean',
29+
description:
30+
'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.',
31+
},
2732
} as const satisfies ArgsDef;

src/mcp/tools/run-cloud-test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { VersionService } from '../../services/version.service.js';
1212
import { uploadBinary, uploadFlowZip, verifyAppZip } from '../../methods.js';
1313
import { getCliVersion } from '../../utils/cli.js';
1414
import { fetchCompatibilityData } from '../../utils/compatibility.js';
15+
import { isEncryptionEnabled } from '../../utils/envelope.js';
1516
import { getConsoleUrl } from '../../utils/styling.js';
1617
import { getContext, logStderr } from '../context.js';
1718
import { jsonResult, runTool } from '../helpers.js';
@@ -173,6 +174,10 @@ export function registerRunCloudTest(server: McpServer): void {
173174
});
174175
}
175176

177+
// Client-side envelope encryption (binary/flow/env). No MCP flag, so
178+
// it's env-driven: DCD_ENCRYPT / DCD_ENCRYPT_BINARIES.
179+
const encrypt = isEncryptionEnabled();
180+
176181
// Resolve the binary: existing id, or upload the local file.
177182
let appBinaryId = args.appBinaryId;
178183
if (!appBinaryId) {
@@ -190,6 +195,7 @@ export function registerRunCloudTest(server: McpServer): void {
190195
appBinaryId = await uploadBinary({
191196
auth,
192197
apiUrl,
198+
encrypt,
193199
filePath: args.appFile,
194200
ignoreShaCheck: Boolean(args.ignoreShaCheck),
195201
log: false,
@@ -199,10 +205,12 @@ export function registerRunCloudTest(server: McpServer): void {
199205
const { continueOnFailure = true } = executionPlan.sequence ?? {};
200206
const testSubmissionService = new TestSubmissionService();
201207
const { buffer, fields } = await testSubmissionService.buildTestPayload({
208+
apiUrl,
202209
appBinaryId,
203210
cliVersion,
204211
commonRoot,
205212
continueOnFailure,
213+
encrypt,
206214
executionPlan,
207215
flowFile,
208216
env: args.env ?? [],

src/methods.ts

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ import { SupabaseGateway } from './gateways/supabase-gateway.js';
2020
import { MetadataExtractorService } from './services/metadata-extractor.service.js';
2121
import { TAppMetadata } from './types.js';
2222
import type { AuthContext } from './types/domain/auth.types.js';
23+
import {
24+
type BinaryEnvelope,
25+
encryptFileToPath,
26+
generateDek,
27+
isEncryptionEnabled,
28+
resolveKekPublicKey,
29+
wrapDek,
30+
} from './utils/envelope.js';
2331
import { colors, formatId } from './utils/styling.js';
2432

2533
const mimeTypeLookupByExtension: Record<string, string> = {
@@ -129,13 +137,20 @@ interface UploadBinaryConfig {
129137
auth: AuthContext;
130138
apiUrl: string;
131139
debug?: boolean;
140+
/**
141+
* Encrypt the binary before upload (client-side envelope encryption, #1138).
142+
* Defaults to `DCD_ENCRYPT` / `DCD_ENCRYPT_BINARIES` when unset (see
143+
* {@link isEncryptionEnabled}).
144+
*/
145+
encrypt?: boolean;
132146
filePath: string;
133147
ignoreShaCheck?: boolean;
134148
log?: boolean;
135149
}
136150

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

152167
const startTime = Date.now();
153168
let source: UploadSource | undefined;
169+
let encCleanupDir: string | undefined;
170+
let enc: BinaryEnvelope | undefined;
154171

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

176+
// Encrypt before hashing/upload so the SHA, dedup check, and both uploaders
177+
// all operate on ciphertext (binaries.sha = ciphertext hash, per #1138).
178+
if (encrypt) {
179+
const encrypted = await encryptUploadSource(source, apiUrl, debug);
180+
enc = encrypted.enc;
181+
encCleanupDir = encrypted.cleanupDir;
182+
if (log) {
183+
ux.info(colors.dim(`Encrypting binary before upload (KEK v${enc.kek})`));
184+
}
185+
}
186+
159187
// Calculate SHA hash
160188
const sha = await calculateFileHash(source, debug, log);
161189

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

178206
// Perform the upload
179-
const uploadId = await performUpload({ auth, apiUrl, debug, filePath, sha, source, startTime });
207+
const uploadId = await performUpload({ auth, apiUrl, debug, enc, filePath, sha, source, startTime });
180208

181209
if (log) {
182210
ux.action.stop(colors.success('\n✓ Binary uploaded with ID: ') + formatId(uploadId));
@@ -205,9 +233,54 @@ export const uploadBinary = async (config: UploadBinaryConfig) => {
205233
if (source?.cleanupDir) {
206234
await rm(source.cleanupDir, { recursive: true, force: true }).catch(() => {});
207235
}
236+
if (encCleanupDir) {
237+
await rm(encCleanupDir, { recursive: true, force: true }).catch(() => {});
238+
}
208239
}
209240
};
210241

242+
/**
243+
* Encrypt the prepared upload source in place (dcd#1138): generate a per-upload
244+
* DEK, stream-encrypt `source.diskPath` into a temp ciphertext file, wrap the
245+
* DEK with the environment's pinned KEK public key, and repoint `source` at the
246+
* ciphertext so the SHA, dedup check, and both uploaders operate on ciphertext.
247+
* Returns the envelope metadata (for `binaries.metadata.enc`) plus the temp dir
248+
* to clean up. Throws if no KEK is available for the environment.
249+
*/
250+
async function encryptUploadSource(
251+
source: UploadSource,
252+
apiUrl: string,
253+
debug: boolean,
254+
): Promise<{ enc: BinaryEnvelope; cleanupDir: string }> {
255+
const kek = resolveKekPublicKey(apiUrl);
256+
if (!kek) {
257+
throw new Error(
258+
'Binary encryption was requested but no KEK public key is configured for this environment. ' +
259+
'Set DCD_BINARY_KEK_PUBLIC=<version>:<base64> or pin one in src/config/environments.ts.',
260+
);
261+
}
262+
263+
const dek = generateDek();
264+
const enc = wrapDek(dek, kek);
265+
const cleanupDir = await mkdtemp(path.join(os.tmpdir(), 'dcd-enc-'));
266+
const cipherPath = path.join(cleanupDir, 'binary.enc');
267+
268+
if (debug) {
269+
console.log(`[DEBUG] Encrypting binary with KEK v${kek.version} -> ${cipherPath}`);
270+
}
271+
272+
await encryptFileToPath(source.diskPath, cipherPath, dek, kek.version);
273+
const { size } = await stat(cipherPath);
274+
source.diskPath = cipherPath;
275+
source.size = size;
276+
277+
if (debug) {
278+
console.log(`[DEBUG] Ciphertext size: ${(size / 1024 / 1024).toFixed(2)} MB`);
279+
}
280+
281+
return { enc, cleanupDir };
282+
}
283+
211284
/**
212285
* Disk-backed description of the binary to upload. Every upload path streams
213286
* from `diskPath` instead of materializing the file in memory — a 1.5 GB iOS
@@ -416,6 +489,8 @@ interface PerformUploadConfig {
416489
auth: AuthContext;
417490
apiUrl: string;
418491
debug: boolean;
492+
/** Envelope metadata when the binary was encrypted (#1138); undefined otherwise. */
493+
enc?: BinaryEnvelope;
419494
filePath: string;
420495
sha: string | undefined;
421496
source: UploadSource;
@@ -694,13 +769,17 @@ function validateUploadResults(
694769
* @returns Promise resolving to upload ID
695770
*/
696771
async function performUpload(config: PerformUploadConfig): Promise<string> {
697-
const { filePath, apiUrl, auth, source, sha, debug, startTime } = config;
772+
const { filePath, apiUrl, auth, enc, source, sha, debug, startTime } = config;
698773

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

702-
// Extract app metadata
777+
// Extract app metadata from the original (plaintext) file. Attach the
778+
// envelope so it lands on binaries.metadata.enc (#1138).
703779
const metadata = await extractBinaryMetadata(filePath, debug);
780+
if (enc) {
781+
metadata.enc = enc;
782+
}
704783

705784
const env = inferEnvFromApiUrl(apiUrl);
706785

0 commit comments

Comments
 (0)