@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/types.d.ts CHANGED
@@ -1,4 +1,11 @@
1
+ import type { BinaryEnvelope } from './utils/envelope.js';
1
2
  export type TAppMetadata = {
2
3
  appId: string;
3
4
  platform: 'android' | 'ios';
5
+ /**
6
+ * Present when the binary was client-side envelope-encrypted before upload
7
+ * (dcd#1138). Stored on `binaries.metadata.enc`; the platform reads it to
8
+ * release the DEK and decrypt. Absent = legacy plaintext upload.
9
+ */
10
+ enc?: BinaryEnvelope;
4
11
  };
@@ -1,4 +1,18 @@
1
1
  import { DeviceMatrixConfig } from '../types/domain/device.types.js';
2
+ /**
3
+ * Refuse to submit a device matrix to an API that cannot honour it.
4
+ *
5
+ * The estimate endpoint is only called when a matrix was actually requested, so
6
+ * a null estimate (the gateway maps 404/405 to null) means the API predates the
7
+ * feature. That API would **silently strip** the unknown `deviceMatrix` field —
8
+ * its ValidationPipe runs `whitelist: true, forbidNonWhitelisted: false` — and
9
+ * run every flow on a single default device, exiting 0. The user would believe
10
+ * they had tested N devices when they tested one: the exact silent
11
+ * under-testing the device matrix exists to prevent. Fail loudly instead.
12
+ *
13
+ * @throws CliError when a matrix was requested but the API does not support it.
14
+ */
15
+ export declare function assertMatrixSupported(deviceMatrix: DeviceMatrixConfig[], estimate: unknown | null): void;
2
16
  /**
3
17
  * Parse repeated `--ios-device-matrix <device>:<version>` and
4
18
  * `--android-device-matrix <device>:<apiLevel>[:play]` flags into an explicit device
@@ -1,5 +1,27 @@
1
1
  import { isIosMatrixConfig, } from '../types/domain/device.types.js';
2
2
  import { CliError } from './cli.js';
3
+ /**
4
+ * Refuse to submit a device matrix to an API that cannot honour it.
5
+ *
6
+ * The estimate endpoint is only called when a matrix was actually requested, so
7
+ * a null estimate (the gateway maps 404/405 to null) means the API predates the
8
+ * feature. That API would **silently strip** the unknown `deviceMatrix` field —
9
+ * its ValidationPipe runs `whitelist: true, forbidNonWhitelisted: false` — and
10
+ * run every flow on a single default device, exiting 0. The user would believe
11
+ * they had tested N devices when they tested one: the exact silent
12
+ * under-testing the device matrix exists to prevent. Fail loudly instead.
13
+ *
14
+ * @throws CliError when a matrix was requested but the API does not support it.
15
+ */
16
+ export function assertMatrixSupported(deviceMatrix, estimate) {
17
+ if (deviceMatrix.length === 0 || estimate)
18
+ return;
19
+ throw new CliError('This DeviceCloud API does not support device matrices, so ' +
20
+ '--ios-device-matrix / --android-device-matrix cannot be honoured. ' +
21
+ 'Submitting anyway would silently run every flow on a single default ' +
22
+ 'device and report success. Upgrade the API, or drop the matrix flags ' +
23
+ 'and use --ios-device / --android-device for a single-device run.');
24
+ }
3
25
  /**
4
26
  * Parse repeated `--ios-device-matrix <device>:<version>` and
5
27
  * `--android-device-matrix <device>:<apiLevel>[:play]` flags into an explicit device
@@ -0,0 +1,73 @@
1
+ export declare const CHUNK_SIZE: number;
2
+ /** `binaries.metadata.enc` / `uploads.metadata.enc` shape (binary + flow zip). */
3
+ export interface BinaryEnvelope {
4
+ v: number;
5
+ kek: number;
6
+ wrapped_key: string;
7
+ }
8
+ /**
9
+ * `results.env.enc` shape (#1152). Same wrapped-DEK fields as a binary, plus the
10
+ * env ciphertext carried **inline** (the env map is tiny, so a single DCDE
11
+ * segment rides in-column rather than as a separate uploaded blob).
12
+ */
13
+ export interface EnvEnvelope extends BinaryEnvelope {
14
+ /** base64 of a single-segment DCDE container of `JSON.stringify(env)`. */
15
+ ciphertext: string;
16
+ }
17
+ /**
18
+ * Whether client-side envelope encryption is on. Explicit `flag` (the
19
+ * `--encrypt` CLI flag) wins; otherwise `DCD_ENCRYPT=1` enables it for binary,
20
+ * flow, and env, and the legacy `DCD_ENCRYPT_BINARIES=1` is kept as an alias.
21
+ * When on, the binary, the flow zip, and the env map are each encrypted with
22
+ * their **own** per-upload DEK (all wrapped under the same per-env KEK).
23
+ */
24
+ export declare function isEncryptionEnabled(flag?: boolean): boolean;
25
+ /** Pinned KEK public key (base64 raw 32-byte X25519) + version, per env. */
26
+ interface KekPublicKey {
27
+ version: number;
28
+ keyRaw: Buffer;
29
+ }
30
+ /**
31
+ * Resolve the KEK public key for the environment behind `apiUrl`. Order:
32
+ * 1. `DCD_BINARY_KEK_PUBLIC` env override (`<version>:<base64>`, e.g. `1:AAAA…`)
33
+ * — lets the feature be exercised before keys are pinned in the release.
34
+ * 2. the pinned `ENVIRONMENTS[env].kekPublicKey`.
35
+ * Returns null when no key is available (encryption cannot proceed).
36
+ */
37
+ export declare function resolveKekPublicKey(apiUrl: string): KekPublicKey | null;
38
+ /**
39
+ * Wrap a DEK for the given KEK public key (X25519 sealed box). Produces base64 of
40
+ * `ephPub(32) || iv(12) || ciphertext(32) || tag(16)`.
41
+ */
42
+ export declare function wrapDek(dek: Buffer, kek: KekPublicKey): BinaryEnvelope;
43
+ /**
44
+ * Stream-encrypt `srcPath` into a DCDE container at `destPath` using `dek`, in
45
+ * constant memory (one chunk buffered at a time). `kekVersion` is written into
46
+ * the header (mirrors the wrapped-key's KEK version).
47
+ */
48
+ export declare function encryptFileToPath(srcPath: string, destPath: string, dek: Buffer, kekVersion: number, chunkSize?: number): Promise<void>;
49
+ /**
50
+ * In-memory twin of {@link encryptFileToPath}: encrypt `plaintext` into a DCDE
51
+ * container Buffer using `dek`. Byte-identical wire format (same 17-byte header,
52
+ * same per-segment nonce/tag scheme), for payloads already held in memory (flow
53
+ * zips, the env map). A payload no larger than `chunkSize` is a single segment.
54
+ */
55
+ export declare function encryptToContainer(plaintext: Buffer, dek: Buffer, kekVersion: number, chunkSize?: number): Buffer;
56
+ /**
57
+ * Encrypt a flow zip buffer with its own per-upload DEK. Returns the ciphertext
58
+ * (a DCDE container) plus the envelope for `uploads.metadata.enc`. `uploads.sha`
59
+ * must be recomputed from the returned ciphertext by the caller.
60
+ */
61
+ export declare function encryptFlowBuffer(buffer: Buffer, kek: KekPublicKey): {
62
+ ciphertext: Buffer;
63
+ enc: BinaryEnvelope;
64
+ };
65
+ /**
66
+ * Encrypt the `--env KEY=VALUE` map with its own per-submission DEK into the
67
+ * `results.env.enc` envelope (#1152). The full map is serialized, encrypted as a
68
+ * single-segment container, and carried inline as base64 `ciphertext`.
69
+ */
70
+ export declare function encryptEnv(env: Record<string, string>, kek: KekPublicKey): EnvEnvelope;
71
+ /** Generate a fresh per-upload DEK. */
72
+ export declare function generateDek(): Buffer;
73
+ export {};
@@ -0,0 +1,220 @@
1
+ import { createCipheriv, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, } from 'node:crypto';
2
+ import { open, stat } from 'node:fs/promises';
3
+ import { inferEnvFromApiUrl } from '../config/environments.js';
4
+ import { ENVIRONMENTS } from '../config/environments.js';
5
+ /**
6
+ * Client-side envelope encryption of app binaries before upload (dcd#1138).
7
+ *
8
+ * The CLI is the *encrypt* half of the contract; the platform (api +
9
+ * simulators, in the `dcd` repo) is the *decrypt* half. This module MUST stay
10
+ * byte-compatible with `api/src/common/crypto/envelope.ts` — the wire format is
11
+ * specified in `dcd/docs/binary-envelope-encryption.md`.
12
+ *
13
+ * Scheme: a per-upload random 256-bit DEK encrypts the binary (chunked
14
+ * AES-256-GCM); the DEK is wrapped with the environment's pinned X25519 KEK
15
+ * public key (sealed box). Only the platform API holds the KEK private half, so
16
+ * every storage/transport tier sees ciphertext only.
17
+ */
18
+ const MAGIC = Buffer.from('DCDE', 'ascii');
19
+ const CONTAINER_VERSION = 1;
20
+ const NONCE_LEN = 12;
21
+ const NONCE_PREFIX_LEN = 7;
22
+ const DEK_LEN = 32;
23
+ export const CHUNK_SIZE = 1024 * 1024; // 1 MiB plaintext segments
24
+ const HKDF_INFO = Buffer.from('dcd-binary-dek-wrap-v1', 'ascii');
25
+ // DER prefix that turns a raw 32-byte X25519 public key into an importable SPKI.
26
+ const SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex');
27
+ /**
28
+ * Whether client-side envelope encryption is on. Explicit `flag` (the
29
+ * `--encrypt` CLI flag) wins; otherwise `DCD_ENCRYPT=1` enables it for binary,
30
+ * flow, and env, and the legacy `DCD_ENCRYPT_BINARIES=1` is kept as an alias.
31
+ * When on, the binary, the flow zip, and the env map are each encrypted with
32
+ * their **own** per-upload DEK (all wrapped under the same per-env KEK).
33
+ */
34
+ export function isEncryptionEnabled(flag) {
35
+ if (flag !== undefined)
36
+ return flag;
37
+ return (process.env.DCD_ENCRYPT === '1' || process.env.DCD_ENCRYPT_BINARIES === '1');
38
+ }
39
+ function x25519PublicFromRaw(raw) {
40
+ return createPublicKey({
41
+ key: Buffer.concat([SPKI_PREFIX, raw]),
42
+ format: 'der',
43
+ type: 'spki',
44
+ });
45
+ }
46
+ /**
47
+ * Resolve the KEK public key for the environment behind `apiUrl`. Order:
48
+ * 1. `DCD_BINARY_KEK_PUBLIC` env override (`<version>:<base64>`, e.g. `1:AAAA…`)
49
+ * — lets the feature be exercised before keys are pinned in the release.
50
+ * 2. the pinned `ENVIRONMENTS[env].kekPublicKey`.
51
+ * Returns null when no key is available (encryption cannot proceed).
52
+ */
53
+ export function resolveKekPublicKey(apiUrl) {
54
+ const override = process.env.DCD_BINARY_KEK_PUBLIC;
55
+ if (override) {
56
+ const [versionPart, b64] = override.split(':');
57
+ const version = Number(versionPart);
58
+ if (b64 && Number.isInteger(version)) {
59
+ const keyRaw = Buffer.from(b64, 'base64');
60
+ if (keyRaw.length === 32)
61
+ return { version, keyRaw };
62
+ }
63
+ throw new Error('DCD_BINARY_KEK_PUBLIC must be "<version>:<base64 32-byte X25519 public key>"');
64
+ }
65
+ const env = inferEnvFromApiUrl(apiUrl);
66
+ const pinned = ENVIRONMENTS[env].kekPublicKey;
67
+ if (!pinned)
68
+ return null;
69
+ const keyRaw = Buffer.from(pinned.key, 'base64');
70
+ if (keyRaw.length !== 32) {
71
+ throw new Error(`Pinned KEK public key for ${env} is not a 32-byte key`);
72
+ }
73
+ return { version: pinned.version, keyRaw };
74
+ }
75
+ /**
76
+ * Wrap a DEK for the given KEK public key (X25519 sealed box). Produces base64 of
77
+ * `ephPub(32) || iv(12) || ciphertext(32) || tag(16)`.
78
+ */
79
+ export function wrapDek(dek, kek) {
80
+ const eph = generateKeyPairSync('x25519');
81
+ const ephPubRaw = eph.publicKey
82
+ .export({ type: 'spki', format: 'der' })
83
+ .subarray(SPKI_PREFIX.length);
84
+ const shared = diffieHellman({
85
+ privateKey: eph.privateKey,
86
+ publicKey: x25519PublicFromRaw(kek.keyRaw),
87
+ });
88
+ const key = Buffer.from(hkdfSync('sha256', shared, Buffer.alloc(0), HKDF_INFO, 32));
89
+ const iv = randomBytes(NONCE_LEN);
90
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
91
+ const ct = Buffer.concat([cipher.update(dek), cipher.final()]);
92
+ const wrapped = Buffer.concat([
93
+ ephPubRaw,
94
+ iv,
95
+ ct,
96
+ cipher.getAuthTag(),
97
+ ]).toString('base64');
98
+ return { v: CONTAINER_VERSION, kek: kek.version, wrapped_key: wrapped };
99
+ }
100
+ function segmentNonce(prefix, index, isLast) {
101
+ const nonce = Buffer.alloc(NONCE_LEN);
102
+ prefix.copy(nonce, 0, 0, NONCE_PREFIX_LEN);
103
+ nonce.writeUInt32BE(index, NONCE_PREFIX_LEN);
104
+ nonce.writeUInt8(isLast ? 1 : 0, NONCE_PREFIX_LEN + 4);
105
+ return nonce;
106
+ }
107
+ /**
108
+ * Stream-encrypt `srcPath` into a DCDE container at `destPath` using `dek`, in
109
+ * constant memory (one chunk buffered at a time). `kekVersion` is written into
110
+ * the header (mirrors the wrapped-key's KEK version).
111
+ */
112
+ export async function encryptFileToPath(srcPath, destPath, dek, kekVersion, chunkSize = CHUNK_SIZE) {
113
+ if (dek.length !== DEK_LEN) {
114
+ throw new Error(`DEK must be ${DEK_LEN} bytes`);
115
+ }
116
+ const { size } = await stat(srcPath);
117
+ const noncePrefix = randomBytes(NONCE_PREFIX_LEN);
118
+ const header = Buffer.alloc(17);
119
+ MAGIC.copy(header, 0);
120
+ header.writeUInt8(CONTAINER_VERSION, 4);
121
+ header.writeUInt8(kekVersion, 5);
122
+ header.writeUInt32BE(chunkSize, 6);
123
+ noncePrefix.copy(header, 10);
124
+ const input = await open(srcPath, 'r');
125
+ const output = await open(destPath, 'w');
126
+ try {
127
+ await output.write(header);
128
+ const buf = Buffer.alloc(chunkSize);
129
+ let index = 0;
130
+ let readTotal = 0;
131
+ // size 0 → no segments (matches the API's empty-input handling).
132
+ while (readTotal < size) {
133
+ const { bytesRead } = await input.read(buf, 0, chunkSize, null);
134
+ if (bytesRead === 0)
135
+ break;
136
+ readTotal += bytesRead;
137
+ const isLast = readTotal >= size;
138
+ const cipher = createCipheriv('aes-256-gcm', dek, segmentNonce(noncePrefix, index, isLast));
139
+ const ct = Buffer.concat([
140
+ cipher.update(buf.subarray(0, bytesRead)),
141
+ cipher.final(),
142
+ ]);
143
+ await output.write(ct);
144
+ await output.write(cipher.getAuthTag());
145
+ index += 1;
146
+ if (isLast)
147
+ break;
148
+ }
149
+ }
150
+ finally {
151
+ await input.close();
152
+ await output.close();
153
+ }
154
+ }
155
+ /**
156
+ * In-memory twin of {@link encryptFileToPath}: encrypt `plaintext` into a DCDE
157
+ * container Buffer using `dek`. Byte-identical wire format (same 17-byte header,
158
+ * same per-segment nonce/tag scheme), for payloads already held in memory (flow
159
+ * zips, the env map). A payload no larger than `chunkSize` is a single segment.
160
+ */
161
+ export function encryptToContainer(plaintext, dek, kekVersion, chunkSize = CHUNK_SIZE) {
162
+ if (dek.length !== DEK_LEN) {
163
+ throw new Error(`DEK must be ${DEK_LEN} bytes`);
164
+ }
165
+ const noncePrefix = randomBytes(NONCE_PREFIX_LEN);
166
+ const header = Buffer.alloc(17);
167
+ MAGIC.copy(header, 0);
168
+ header.writeUInt8(CONTAINER_VERSION, 4);
169
+ header.writeUInt8(kekVersion, 5);
170
+ header.writeUInt32BE(chunkSize, 6);
171
+ noncePrefix.copy(header, 10);
172
+ const parts = [header];
173
+ // size 0 → no segments (matches encryptFileToPath / the API's empty input).
174
+ let index = 0;
175
+ let offset = 0;
176
+ while (offset < plaintext.length) {
177
+ const end = Math.min(offset + chunkSize, plaintext.length);
178
+ const isLast = end >= plaintext.length;
179
+ const cipher = createCipheriv('aes-256-gcm', dek, segmentNonce(noncePrefix, index, isLast));
180
+ const ct = Buffer.concat([
181
+ cipher.update(plaintext.subarray(offset, end)),
182
+ cipher.final(),
183
+ ]);
184
+ parts.push(ct, cipher.getAuthTag());
185
+ offset = end;
186
+ index += 1;
187
+ }
188
+ return Buffer.concat(parts);
189
+ }
190
+ /**
191
+ * Encrypt a flow zip buffer with its own per-upload DEK. Returns the ciphertext
192
+ * (a DCDE container) plus the envelope for `uploads.metadata.enc`. `uploads.sha`
193
+ * must be recomputed from the returned ciphertext by the caller.
194
+ */
195
+ export function encryptFlowBuffer(buffer, kek) {
196
+ const dek = generateDek();
197
+ const enc = wrapDek(dek, kek);
198
+ const ciphertext = encryptToContainer(buffer, dek, kek.version);
199
+ return { ciphertext, enc };
200
+ }
201
+ /**
202
+ * Encrypt the `--env KEY=VALUE` map with its own per-submission DEK into the
203
+ * `results.env.enc` envelope (#1152). The full map is serialized, encrypted as a
204
+ * single-segment container, and carried inline as base64 `ciphertext`.
205
+ */
206
+ export function encryptEnv(env, kek) {
207
+ const dek = generateDek();
208
+ const { wrapped_key } = wrapDek(dek, kek);
209
+ const container = encryptToContainer(Buffer.from(JSON.stringify(env), 'utf8'), dek, kek.version);
210
+ return {
211
+ v: CONTAINER_VERSION,
212
+ kek: kek.version,
213
+ wrapped_key,
214
+ ciphertext: container.toString('base64'),
215
+ };
216
+ }
217
+ /** Generate a fresh per-upload DEK. */
218
+ export function generateDek() {
219
+ return randomBytes(DEK_LEN);
220
+ }
package/package.json CHANGED
@@ -9,9 +9,9 @@
9
9
  "@modelcontextprotocol/sdk": "^1.29.0",
10
10
  "@supabase/supabase-js": "^2.108.2",
11
11
  "bplist-parser": "^0.3.2",
12
- "chalk": "^5.6.2",
12
+ "chalk": "^6.0.0",
13
13
  "citty": "^0.2.2",
14
- "js-yaml": "^5.0.0",
14
+ "js-yaml": "^5.2.2",
15
15
  "node-apk": "^1.2.1",
16
16
  "node-stream-zip": "^1.15.0",
17
17
  "plist": "^5.0.0",
@@ -31,8 +31,7 @@
31
31
  "chai": "^6.2.2",
32
32
  "eslint": "^10.5.0",
33
33
  "eslint-config-prettier": "^10.1.8",
34
- "eslint-plugin-import": "^2.32.0",
35
- "eslint-plugin-unicorn": "^69.0.0",
34
+ "eslint-plugin-unicorn": "^72.0.0",
36
35
  "husky": "^9.1.7",
37
36
  "mocha": "^11.7.6",
38
37
  "prettier": "^3.8.4",
@@ -60,7 +59,7 @@
60
59
  "type": "git",
61
60
  "url": "https://devicecloud.dev"
62
61
  },
63
- "version": "5.2.0-beta.2",
62
+ "version": "5.2.0-beta.4",
64
63
  "bugs": {
65
64
  "url": "https://discord.gg/gm3mJwcNw8"
66
65
  },