@devicecloud.dev/dcd 5.2.0-beta.2 → 5.2.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/methods.js CHANGED
@@ -11,6 +11,7 @@ import { inferEnvFromApiUrl } from './config/environments.js';
11
11
  import { ApiError, ApiGateway } from './gateways/api-gateway.js';
12
12
  import { SupabaseGateway } from './gateways/supabase-gateway.js';
13
13
  import { MetadataExtractorService } from './services/metadata-extractor.service.js';
14
+ import { encryptFileToPath, generateDek, isEncryptionEnabled, resolveKekPublicKey, wrapDek, } from './utils/envelope.js';
14
15
  import { colors, formatId } from './utils/styling.js';
15
16
  const mimeTypeLookupByExtension = {
16
17
  apk: 'application/vnd.android.package-archive',
@@ -68,7 +69,7 @@ export const compressFilesFromRelativePath = async (basePath, files, commonRoot)
68
69
  return zipToBuffer(zipfile);
69
70
  };
70
71
  export const verifyAppZip = async (zipPath) => {
71
- // eslint-disable-next-line import/namespace, new-cap
72
+ // eslint-disable-next-line new-cap
72
73
  const zip = await new StreamZip.async({
73
74
  file: zipPath,
74
75
  storeEntries: true,
@@ -94,6 +95,7 @@ export const verifyAppZip = async (zipPath) => {
94
95
  };
95
96
  export const uploadBinary = async (config) => {
96
97
  const { filePath, apiUrl, auth, ignoreShaCheck = false, log = true, debug = false } = config;
98
+ const encrypt = isEncryptionEnabled(config.encrypt);
97
99
  if (log) {
98
100
  ux.action.start(colors.bold('Checking and uploading binary'), colors.dim('Initializing'), {
99
101
  stdout: true,
@@ -107,14 +109,20 @@ export const uploadBinary = async (config) => {
107
109
  }
108
110
  const startTime = Date.now();
109
111
  let source;
112
+ let encCleanupDir;
113
+ let enc;
110
114
  try {
111
115
  // Prepare file for upload
112
116
  source = await prepareFileForUpload(filePath, debug, startTime);
113
- // Calculate SHA hash
114
- const sha = await calculateFileHash(source, debug, log);
115
- // Check for existing upload with same SHA
116
- if (!ignoreShaCheck && sha) {
117
- const { exists, binaryId } = await checkExistingUpload(apiUrl, auth, sha, debug);
117
+ // Hash the PLAINTEXT first, before any encryption (dcd#1168). Encryption
118
+ // uses a fresh random DEK per upload, so the ciphertext hash differs every
119
+ // time and cannot dedup — the plaintext hash is the only stable key. Doing it
120
+ // in this order also means a dedup hit skips the encryption work entirely,
121
+ // not just the upload.
122
+ const shaPlain = await calculateFileHash(source, debug, log);
123
+ // Check for an existing upload before spending anything on encryption.
124
+ if (!ignoreShaCheck && shaPlain) {
125
+ const { exists, binaryId } = await checkExistingUpload(apiUrl, auth, encrypt ? { encrypted: true, shaPlain } : { sha: shaPlain }, debug);
118
126
  if (exists && binaryId) {
119
127
  if (log) {
120
128
  ux.info(colors.dim('SHA hash matches existing binary with ID: ') + formatId(binaryId) + colors.dim(', skipping upload. Force upload with --ignore-sha-check'));
@@ -123,8 +131,33 @@ export const uploadBinary = async (config) => {
123
131
  return binaryId;
124
132
  }
125
133
  }
134
+ // Encrypt after the dedup check, so the SHA sent at finalise, and both
135
+ // uploaders, operate on ciphertext (binaries.sha = ciphertext hash, #1138).
136
+ if (encrypt) {
137
+ const encrypted = await encryptUploadSource(source, apiUrl, debug);
138
+ enc = encrypted.enc;
139
+ encCleanupDir = encrypted.cleanupDir;
140
+ if (log) {
141
+ ux.info(colors.dim(`Encrypting binary before upload (KEK v${enc.kek})`));
142
+ }
143
+ }
144
+ // Re-hash once encrypted: what lands in storage is the ciphertext, and every
145
+ // downstream verifySha hashes the bytes it actually holds (no DEK required).
146
+ const sha = encrypt ? await calculateFileHash(source, debug, false) : shaPlain;
126
147
  // Perform the upload
127
- const uploadId = await performUpload({ auth, apiUrl, debug, filePath, sha, source, startTime });
148
+ const uploadId = await performUpload({
149
+ auth,
150
+ apiUrl,
151
+ debug,
152
+ enc,
153
+ filePath,
154
+ sha,
155
+ // Only encrypted uploads record a plaintext hash; it is what makes the
156
+ // next encrypted run of this binary dedupable.
157
+ shaPlain: encrypt ? shaPlain : undefined,
158
+ source,
159
+ startTime,
160
+ });
128
161
  if (log) {
129
162
  ux.action.stop(colors.success('\n✓ Binary uploaded with ID: ') + formatId(uploadId));
130
163
  }
@@ -150,8 +183,41 @@ export const uploadBinary = async (config) => {
150
183
  if (source?.cleanupDir) {
151
184
  await rm(source.cleanupDir, { recursive: true, force: true }).catch(() => { });
152
185
  }
186
+ if (encCleanupDir) {
187
+ await rm(encCleanupDir, { recursive: true, force: true }).catch(() => { });
188
+ }
153
189
  }
154
190
  };
191
+ /**
192
+ * Encrypt the prepared upload source in place (dcd#1138): generate a per-upload
193
+ * DEK, stream-encrypt `source.diskPath` into a temp ciphertext file, wrap the
194
+ * DEK with the environment's pinned KEK public key, and repoint `source` at the
195
+ * ciphertext so the SHA, dedup check, and both uploaders operate on ciphertext.
196
+ * Returns the envelope metadata (for `binaries.metadata.enc`) plus the temp dir
197
+ * to clean up. Throws if no KEK is available for the environment.
198
+ */
199
+ async function encryptUploadSource(source, apiUrl, debug) {
200
+ const kek = resolveKekPublicKey(apiUrl);
201
+ if (!kek) {
202
+ throw new Error('Binary encryption was requested but no KEK public key is configured for this environment. ' +
203
+ 'Set DCD_BINARY_KEK_PUBLIC=<version>:<base64> or pin one in src/config/environments.ts.');
204
+ }
205
+ const dek = generateDek();
206
+ const enc = wrapDek(dek, kek);
207
+ const cleanupDir = await mkdtemp(path.join(os.tmpdir(), 'dcd-enc-'));
208
+ const cipherPath = path.join(cleanupDir, 'binary.enc');
209
+ if (debug) {
210
+ console.log(`[DEBUG] Encrypting binary with KEK v${kek.version} -> ${cipherPath}`);
211
+ }
212
+ await encryptFileToPath(source.diskPath, cipherPath, dek, kek.version);
213
+ const { size } = await stat(cipherPath);
214
+ source.diskPath = cipherPath;
215
+ source.size = size;
216
+ if (debug) {
217
+ console.log(`[DEBUG] Ciphertext size: ${(size / 1024 / 1024).toFixed(2)} MB`);
218
+ }
219
+ return { enc, cleanupDir };
220
+ }
155
221
  /**
156
222
  * Prepares a file for upload: .app directories are zipped to a temp file on
157
223
  * disk; everything else is described in place. Nothing is read into memory.
@@ -256,21 +322,34 @@ async function calculateFileHash(source, debug, log) {
256
322
  }
257
323
  }
258
324
  /**
259
- * Checks if an upload with the same SHA already exists
325
+ * Checks whether a matching binary has already been uploaded.
326
+ *
327
+ * `lookup` is `{ sha }` for a plaintext upload, or `{ shaPlain, encrypted: true }`
328
+ * for an encrypted one (dcd#1168) — see {@link ApiGateway.checkForExistingUpload}.
329
+ *
330
+ * When asking as an encrypting client, a hit is only honoured if the server
331
+ * confirms the matched binary is itself encrypted. Any binary is a *plausible*
332
+ * match on plaintext hash, including a previously-uploaded plaintext copy of the
333
+ * same app, and reusing that would hand back an unencrypted binary while the user
334
+ * had asked for encryption. The server applies the same predicate; this is the
335
+ * client refusing to depend on that, so an older or misbehaving deployment
336
+ * degrades into a redundant upload rather than a silent loss of encryption.
337
+ *
260
338
  * @param apiUrl API base URL
261
339
  * @param auth AuthContext carrying request headers
262
- * @param sha SHA-256 hash to check
340
+ * @param lookup Dedup key — plaintext hash for encrypted uploads, else the sha
263
341
  * @param debug Whether debug logging is enabled
264
342
  * @returns Promise resolving to object with exists flag and optional binaryId
265
343
  */
266
- async function checkExistingUpload(apiUrl, auth, sha, debug) {
344
+ async function checkExistingUpload(apiUrl, auth, lookup, debug) {
267
345
  try {
268
346
  if (debug) {
269
347
  console.log('[DEBUG] Checking for existing upload with matching SHA...');
348
+ console.log(`[DEBUG] Lookup: ${JSON.stringify(lookup)}`);
270
349
  console.log(`[DEBUG] Target endpoint: ${apiUrl}/uploads/checkForExistingUpload`);
271
350
  }
272
351
  const shaCheckStartTime = Date.now();
273
- const { appBinaryId, exists } = await ApiGateway.checkForExistingUpload(apiUrl, auth, sha);
352
+ const { appBinaryId, encrypted, exists } = await ApiGateway.checkForExistingUpload(apiUrl, auth, lookup);
274
353
  if (debug) {
275
354
  console.log(`[DEBUG] SHA check completed in ${Date.now() - shaCheckStartTime}ms`);
276
355
  console.log(`[DEBUG] Existing binary found: ${exists}`);
@@ -278,6 +357,12 @@ async function checkExistingUpload(apiUrl, auth, sha, debug) {
278
357
  console.log(`[DEBUG] Existing binary ID: ${appBinaryId}`);
279
358
  }
280
359
  }
360
+ if (exists && lookup.encrypted && encrypted !== true) {
361
+ if (debug) {
362
+ console.log('[DEBUG] Ignoring dedup hit: encryption was requested but the matched binary is not encrypted');
363
+ }
364
+ return { exists: false };
365
+ }
281
366
  return { binaryId: appBinaryId, exists };
282
367
  }
283
368
  catch (error) {
@@ -504,11 +589,15 @@ function validateUploadResults(supabaseSuccess, backblazeSuccess, lastError, b2,
504
589
  * @returns Promise resolving to upload ID
505
590
  */
506
591
  async function performUpload(config) {
507
- const { filePath, apiUrl, auth, source, sha, debug, startTime } = config;
592
+ const { filePath, apiUrl, auth, enc, source, sha, shaPlain, debug, startTime } = config;
508
593
  // Request upload URL and paths
509
594
  const { id, tempPath, finalPath, b2 } = await requestUploadPaths(apiUrl, auth, filePath, source.size, debug);
510
- // Extract app metadata
595
+ // Extract app metadata from the original (plaintext) file. Attach the
596
+ // envelope so it lands on binaries.metadata.enc (#1138).
511
597
  const metadata = await extractBinaryMetadata(filePath, debug);
598
+ if (enc) {
599
+ metadata.enc = enc;
600
+ }
512
601
  const env = inferEnvFromApiUrl(apiUrl);
513
602
  // Upload to Backblaze first (primary)
514
603
  const backblazeResult = await handleBackblazeUpload({
@@ -554,6 +643,7 @@ async function performUpload(config) {
554
643
  path: tempPath,
555
644
  // sha is undefined when hash calculation failed — omit it explicitly
556
645
  ...(sha ? { sha } : {}),
646
+ ...(shaPlain ? { shaPlain } : {}),
557
647
  supabaseSuccess: supabaseResult.success,
558
648
  });
559
649
  if (debug) {
@@ -4,6 +4,7 @@ export interface TestSubmissionConfig {
4
4
  androidApiLevel?: string;
5
5
  androidDevice?: string;
6
6
  androidNoSnapshot?: boolean;
7
+ apiUrl?: string;
7
8
  appBinaryId: string;
8
9
  cliVersion: string;
9
10
  commonRoot: string;
@@ -12,6 +13,12 @@ export interface TestSubmissionConfig {
12
13
  deviceLocale?: string;
13
14
  deviceMatrix?: DeviceMatrixConfig[];
14
15
  disableAnimations?: boolean;
16
+ /**
17
+ * Encrypt the flow zip and env vars before upload (#1151/#1152), each with its
18
+ * own per-upload DEK wrapped under the environment KEK. Requires `apiUrl` to
19
+ * resolve the pinned KEK public key.
20
+ */
21
+ encrypt?: boolean;
15
22
  env?: string[];
16
23
  executionPlan: IExecutionPlan;
17
24
  flowFile: string;
@@ -22,8 +29,6 @@ export interface TestSubmissionConfig {
22
29
  maestroChromeOnboarding?: boolean;
23
30
  maestroVersion: string;
24
31
  metadata?: string[];
25
- mitmHost?: string;
26
- mitmPath?: string;
27
32
  name?: string;
28
33
  orientation?: string;
29
34
  raw?: unknown;
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import * as path from 'node:path';
3
3
  import { compressFilesFromRelativePath } from '../methods.js';
4
+ import { encryptEnv, encryptFlowBuffer, resolveKekPublicKey, } from '../utils/envelope.js';
4
5
  import { toPortableRelativePath } from '../utils/paths.js';
5
6
  const mimeTypeLookupByExtension = {
6
7
  zip: 'application/zip',
@@ -19,7 +20,7 @@ export class TestSubmissionService {
19
20
  * @returns The flow zip buffer, its SHA-256, and the string-encoded fields
20
21
  */
21
22
  async buildTestPayload(config) {
22
- const { appBinaryId, flowFile, executionPlan, commonRoot, cliVersion, env = [], metadata = [], googlePlay = false, androidApiLevel, androidDevice, androidNoSnapshot, iOSVersion, iOSDevice, name, runnerType, maestroVersion, deviceLocale, orientation, mitmHost, mitmPath, retry, continueOnFailure = true, report, showCrosshairs, maestroChromeOnboarding, raw, disableAnimations, deviceMatrix, debug = false, logger, } = config;
23
+ const { apiUrl, appBinaryId, encrypt = false, flowFile, executionPlan, commonRoot, cliVersion, env = [], metadata = [], googlePlay = false, androidApiLevel, androidDevice, androidNoSnapshot, iOSVersion, iOSDevice, name, runnerType, maestroVersion, deviceLocale, orientation, retry, continueOnFailure = true, report, showCrosshairs, maestroChromeOnboarding, raw, disableAnimations, deviceMatrix, debug = false, logger, } = config;
23
24
  const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, referencedFiles, sequence, workspaceConfig, } = executionPlan;
24
25
  const { flows: sequentialFlows = [] } = sequence ?? {};
25
26
  const envObject = this.parseKeyValuePairs(env);
@@ -42,7 +43,7 @@ export class TestSubmissionService {
42
43
  }
43
44
  }
44
45
  this.logDebug(debug, logger, `[DEBUG] Compressing files from path: ${flowFile}`);
45
- const buffer = await compressFilesFromRelativePath(flowFile?.endsWith('.yaml') || flowFile?.endsWith('.yml')
46
+ const plaintextZip = await compressFilesFromRelativePath(flowFile?.endsWith('.yaml') || flowFile?.endsWith('.yml')
46
47
  ? path.dirname(flowFile)
47
48
  : flowFile, [
48
49
  ...new Set([
@@ -51,8 +52,37 @@ export class TestSubmissionService {
51
52
  ...sequentialFlows,
52
53
  ]),
53
54
  ], commonRoot);
54
- this.logDebug(debug, logger, `[DEBUG] Compressed file size: ${buffer.length} bytes`);
55
- // Calculate SHA-256 hash of the flow ZIP
55
+ this.logDebug(debug, logger, `[DEBUG] Compressed file size: ${plaintextZip.length} bytes`);
56
+ // Client-side envelope encryption (#1151 flow zip, #1152 env vars). Each
57
+ // gets its own per-upload DEK wrapped under the environment KEK; the flow
58
+ // zip becomes a DCDE container and `sha` is the CIPHERTEXT hash (uploads.sha
59
+ // stays a ciphertext hash, mirroring binaries.sha). `enc` rides `fields`
60
+ // as a JSON string like every other field, so both the JSON submitFlowTest
61
+ // body and the legacy multipart form carry it identically.
62
+ let buffer = plaintextZip;
63
+ let envObjectToSend = envObject;
64
+ let flowEnc;
65
+ if (encrypt) {
66
+ if (!apiUrl) {
67
+ throw new Error('Encryption requires apiUrl to resolve the KEK public key');
68
+ }
69
+ const kek = resolveKekPublicKey(apiUrl);
70
+ if (!kek) {
71
+ throw new Error('Encryption was requested but no KEK public key is configured for this environment. ' +
72
+ 'Set DCD_BINARY_KEK_PUBLIC=<version>:<base64> or pin one in src/config/environments.ts.');
73
+ }
74
+ const flow = encryptFlowBuffer(plaintextZip, kek);
75
+ buffer = flow.ciphertext;
76
+ flowEnc = flow.enc;
77
+ this.logDebug(debug, logger, `[DEBUG] Encrypting flow zip before upload (KEK v${kek.version}); ciphertext ${buffer.length} bytes`);
78
+ // Only encrypt when there are env vars; an empty map has no secret to
79
+ // protect and stays a plaintext `{}` (no `enc` marker, passes through).
80
+ if (Object.keys(envObject).length > 0) {
81
+ envObjectToSend = { enc: encryptEnv(envObject, kek) };
82
+ this.logDebug(debug, logger, `[DEBUG] Encrypting ${Object.keys(envObject).length} env var(s)`);
83
+ }
84
+ }
85
+ // SHA-256 of what actually gets uploaded (ciphertext when encrypted).
56
86
  const sha = createHash('sha256').update(buffer).digest('hex');
57
87
  this.logDebug(debug, logger, `[DEBUG] Flow ZIP SHA-256: ${sha}`);
58
88
  // String-encoded fields, in the same order and with the same encoding as
@@ -64,7 +94,12 @@ export class TestSubmissionService {
64
94
  fields.flowMetadata = JSON.stringify(this.normalizePathMap(flowMetadata, commonRoot));
65
95
  fields.testFileOverrides = JSON.stringify(this.normalizePathMap(flowOverrides, commonRoot));
66
96
  fields.sequentialFlows = JSON.stringify(this.normalizePaths(sequentialFlows, commonRoot));
67
- fields.env = JSON.stringify(envObject);
97
+ fields.env = JSON.stringify(envObjectToSend);
98
+ // Flow-zip envelope for uploads.metadata.enc (#1151). JSON string so both
99
+ // submission paths carry it like every other field; the API parses it.
100
+ if (flowEnc) {
101
+ fields.enc = JSON.stringify(flowEnc);
102
+ }
68
103
  // Note: googlePlay is now included in configPayload below instead of as a separate field
69
104
  // to work around a FormData parsing issue in the API
70
105
  // Explicit device matrix (one upload, N cells). Only sent when present, so
@@ -94,8 +129,6 @@ export class TestSubmissionService {
94
129
  deviceLocale,
95
130
  googlePlay,
96
131
  maestroVersion,
97
- mitmHost,
98
- mitmPath,
99
132
  orientation,
100
133
  raw: JSON.stringify(raw),
101
134
  report,
@@ -31,6 +31,7 @@ export declare enum EAndroidApiLevels {
31
31
  'thirtyFive' = "35",
32
32
  'thirtyFour' = "34",
33
33
  'thirtyOne' = "31",
34
+ 'thirtySeven' = "37",
34
35
  'thirtySix' = "36",
35
36
  'thirtyThree' = "33",
36
37
  'thirtyTwo' = "32",
@@ -35,6 +35,7 @@ export var EAndroidApiLevels;
35
35
  EAndroidApiLevels["thirtyFive"] = "35";
36
36
  EAndroidApiLevels["thirtyFour"] = "34";
37
37
  EAndroidApiLevels["thirtyOne"] = "31";
38
+ EAndroidApiLevels["thirtySeven"] = "37";
38
39
  EAndroidApiLevels["thirtySix"] = "36";
39
40
  EAndroidApiLevels["thirtyThree"] = "33";
40
41
  EAndroidApiLevels["thirtyTwo"] = "32";