Skip to content

Commit 0b64d1c

Browse files
authored
feat(eas-cli): Ensure non-exempt encryption status (#2843)
* Ensure non-exempt encryption status * Update exemptEncryption.ts * Update CHANGELOG.md * add tests * Update exemptEncryption.ts * Update exemptEncryption.ts * Update exemptEncryption.ts
1 parent fe37600 commit 0b64d1c

File tree

5 files changed

+356
-0
lines changed

5 files changed

+356
-0
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ This is the log of notable changes to EAS CLI and related packages.
88

99
### 🎉 New features
1010

11+
- Prompt to set non-exempt encryption status for the iOS app to support faster store submissions. ([#2843](https://github.com/expo/eas-cli/pull/2843) by [@EvanBacon](https://github.com/EvanBacon))
1112
- Automatically create internal TestFlight group in EAS Submit command. ([#2839](https://github.com/expo/eas-cli/pull/2839) by [@evanbacon](https://github.com/evanbacon))
1213
- Sanitize and generate names for EAS Submit to prevent failures due to invalid characters or taken names. ([#2842](https://github.com/expo/eas-cli/pull/2842) by [@evanbacon](https://github.com/evanbacon))
1314

packages/eas-cli/__mocks__/fs.ts

+3
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,6 @@ if (process.env.TMPDIR) {
1212
(fs.realpath as any).native = jest.fn();
1313

1414
module.exports = fs;
15+
16+
// Ensure requiring node:fs returns the mock too. This is needed for expo/config and expo/json-file.
17+
jest.mock('node:fs', () => fs);

packages/eas-cli/src/build/ios/build.ts

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { IosCredentials } from '../../credentials/ios/types';
1010
import { BuildParamsInput } from '../../graphql/generated';
1111
import { BuildMutation, BuildResult } from '../../graphql/mutations/BuildMutation';
1212
import { ensureBundleIdentifierIsDefinedForManagedProjectAsync } from '../../project/ios/bundleIdentifier';
13+
import { ensureNonExemptEncryptionIsDefinedForManagedProjectAsync } from '../../project/ios/exemptEncryption';
1314
import { resolveXcodeBuildContextAsync } from '../../project/ios/scheme';
1415
import { findApplicationTarget, resolveTargetsAsync } from '../../project/ios/target';
1516
import { BuildRequestSender, JobData, prepareBuildRequestForPlatformAsync } from '../build';
@@ -28,6 +29,7 @@ export async function createIosContextAsync(
2829

2930
if (ctx.workflow === Workflow.MANAGED) {
3031
await ensureBundleIdentifierIsDefinedForManagedProjectAsync(ctx);
32+
await ensureNonExemptEncryptionIsDefinedForManagedProjectAsync(ctx);
3133
}
3234

3335
checkNodeEnvVariable(ctx);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { getConfig } from '@expo/config';
2+
import { vol } from 'memfs';
3+
4+
import * as prompts from '../../../prompts';
5+
import { ensureNonExemptEncryptionIsDefinedForManagedProjectAsync } from '../exemptEncryption';
6+
7+
jest.mock('fs');
8+
jest.mock('../../../prompts');
9+
10+
beforeEach(async () => {
11+
jest.resetAllMocks();
12+
vol.reset();
13+
});
14+
15+
test('prompts non-exempt encryption is not used', async () => {
16+
const projectRoot = '/project';
17+
vol.fromJSON(
18+
{
19+
'package.json': JSON.stringify({}),
20+
'app.json': JSON.stringify({
21+
expo: {
22+
name: 'myproject',
23+
slug: 'myproject',
24+
},
25+
}),
26+
},
27+
projectRoot
28+
);
29+
30+
jest.mocked(prompts.confirmAsync).mockReset().mockResolvedValueOnce(true);
31+
32+
await ensureNonExemptEncryptionIsDefinedForManagedProjectAsync({
33+
projectDir: projectRoot,
34+
exp: getConfig(projectRoot, { skipSDKVersionRequirement: true }).exp,
35+
nonInteractive: false,
36+
});
37+
38+
expect(getConfig(projectRoot, { skipSDKVersionRequirement: true }).rootConfig).toEqual({
39+
expo: {
40+
ios: {
41+
infoPlist: {
42+
ITSAppUsesNonExemptEncryption: false,
43+
},
44+
},
45+
name: 'myproject',
46+
slug: 'myproject',
47+
},
48+
});
49+
});
50+
51+
test('does not prompt if the value is already set', async () => {
52+
const projectRoot = '/project';
53+
vol.fromJSON(
54+
{
55+
'package.json': JSON.stringify({}),
56+
'app.json': JSON.stringify({
57+
name: 'myproject',
58+
slug: 'myproject',
59+
ios: {
60+
infoPlist: {
61+
ITSAppUsesNonExemptEncryption: false,
62+
},
63+
},
64+
}),
65+
},
66+
projectRoot
67+
);
68+
69+
// Reset but no mock values.
70+
jest.mocked(prompts.confirmAsync).mockReset();
71+
72+
await ensureNonExemptEncryptionIsDefinedForManagedProjectAsync({
73+
projectDir: projectRoot,
74+
exp: getConfig(projectRoot, { skipSDKVersionRequirement: true }).exp,
75+
nonInteractive: false,
76+
});
77+
78+
expect(prompts.confirmAsync).toHaveBeenCalledTimes(0);
79+
80+
expect(getConfig(projectRoot, { skipSDKVersionRequirement: true }).rootConfig).toEqual({
81+
ios: {
82+
infoPlist: {
83+
ITSAppUsesNonExemptEncryption: false,
84+
},
85+
},
86+
name: 'myproject',
87+
slug: 'myproject',
88+
});
89+
});
90+
91+
test('prompts non-exempt encryption in CI', async () => {
92+
const projectRoot = '/project';
93+
vol.fromJSON(
94+
{
95+
'package.json': JSON.stringify({}),
96+
'app.json': JSON.stringify({
97+
expo: {
98+
name: 'myproject',
99+
slug: 'myproject',
100+
},
101+
}),
102+
},
103+
projectRoot
104+
);
105+
106+
// Reset but no mock values.
107+
jest.mocked(prompts.confirmAsync).mockReset();
108+
109+
await ensureNonExemptEncryptionIsDefinedForManagedProjectAsync({
110+
projectDir: projectRoot,
111+
exp: getConfig(projectRoot, { skipSDKVersionRequirement: true }).exp,
112+
nonInteractive: false,
113+
});
114+
115+
expect(getConfig(projectRoot, { skipSDKVersionRequirement: true }).rootConfig).toEqual({
116+
expo: {
117+
ios: {
118+
infoPlist: {
119+
ITSAppUsesNonExemptEncryption: false,
120+
},
121+
},
122+
name: 'myproject',
123+
slug: 'myproject',
124+
},
125+
});
126+
});
127+
128+
test('prompts non-exempt encryption is not used but app.config.js', async () => {
129+
const projectRoot = '/project';
130+
vol.fromJSON(
131+
{
132+
'package.json': JSON.stringify({}),
133+
'app.config.js':
134+
`module.exports = ` +
135+
JSON.stringify({
136+
expo: {
137+
name: 'myproject',
138+
slug: 'myproject',
139+
},
140+
}),
141+
},
142+
projectRoot
143+
);
144+
145+
jest.mocked(prompts.confirmAsync).mockReset().mockResolvedValueOnce(true);
146+
147+
await ensureNonExemptEncryptionIsDefinedForManagedProjectAsync({
148+
projectDir: projectRoot,
149+
exp: getConfig(projectRoot, { skipSDKVersionRequirement: true }).exp,
150+
nonInteractive: false,
151+
});
152+
153+
// Not set.
154+
expect(getConfig(projectRoot, { skipSDKVersionRequirement: true }).rootConfig).toEqual({});
155+
});
156+
157+
test('prompts non-exempt encryption is used', async () => {
158+
const projectRoot = '/project';
159+
vol.fromJSON(
160+
{
161+
'package.json': JSON.stringify({}),
162+
'app.json': JSON.stringify({
163+
expo: {
164+
name: 'myproject',
165+
slug: 'myproject',
166+
},
167+
}),
168+
},
169+
projectRoot
170+
);
171+
172+
jest
173+
.mocked(prompts.confirmAsync)
174+
.mockReset()
175+
.mockResolvedValueOnce(false)
176+
// Are you sure?
177+
.mockResolvedValueOnce(true);
178+
179+
await ensureNonExemptEncryptionIsDefinedForManagedProjectAsync({
180+
projectDir: projectRoot,
181+
exp: getConfig(projectRoot, { skipSDKVersionRequirement: true }).exp,
182+
nonInteractive: false,
183+
});
184+
185+
expect(prompts.confirmAsync).toHaveBeenCalledTimes(2);
186+
187+
expect(getConfig(projectRoot, { skipSDKVersionRequirement: true }).rootConfig).toEqual({
188+
expo: {
189+
name: 'myproject',
190+
slug: 'myproject',
191+
},
192+
});
193+
});
194+
195+
test('prompts non-exempt encryption is used but backed out', async () => {
196+
const projectRoot = '/project';
197+
vol.fromJSON(
198+
{
199+
'package.json': JSON.stringify({}),
200+
'app.json': JSON.stringify({
201+
expo: {
202+
name: 'myproject',
203+
slug: 'myproject',
204+
},
205+
}),
206+
},
207+
projectRoot
208+
);
209+
210+
jest
211+
.mocked(prompts.confirmAsync)
212+
.mockReset()
213+
.mockResolvedValueOnce(false)
214+
// Are you sure?
215+
.mockResolvedValueOnce(false);
216+
217+
await ensureNonExemptEncryptionIsDefinedForManagedProjectAsync({
218+
projectDir: projectRoot,
219+
exp: getConfig(projectRoot, { skipSDKVersionRequirement: true }).exp,
220+
nonInteractive: false,
221+
});
222+
223+
expect(getConfig(projectRoot, { skipSDKVersionRequirement: true }).rootConfig).toEqual({
224+
expo: {
225+
name: 'myproject',
226+
slug: 'myproject',
227+
ios: {
228+
infoPlist: {
229+
ITSAppUsesNonExemptEncryption: false,
230+
},
231+
},
232+
},
233+
});
234+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { ExpoConfig, getProjectConfigDescription, modifyConfigAsync } from '@expo/config';
2+
import chalk from 'chalk';
3+
4+
import Log, { learnMore } from '../../log';
5+
import { confirmAsync } from '../../prompts';
6+
7+
/** Non-exempt encryption must be set on every build in App Store Connect, we move it to before the build process to attempt only setting it once for the entire life-cycle of the project. */
8+
export async function ensureNonExemptEncryptionIsDefinedForManagedProjectAsync({
9+
projectDir,
10+
exp,
11+
nonInteractive,
12+
}: {
13+
projectDir: string;
14+
exp: ExpoConfig;
15+
nonInteractive: boolean;
16+
}): Promise<void> {
17+
// TODO: We could add bare workflow support in the future.
18+
// TODO: We could add wizard support for non-exempt encryption in the future.
19+
20+
if (exp.ios?.infoPlist?.ITSAppUsesNonExemptEncryption == null) {
21+
await configureNonExemptEncryptionAsync({
22+
projectDir,
23+
exp,
24+
nonInteractive,
25+
});
26+
} else {
27+
Log.debug(`ITSAppUsesNonExemptEncryption is defined in the app config.`);
28+
}
29+
}
30+
31+
async function configureNonExemptEncryptionAsync({
32+
projectDir,
33+
exp,
34+
nonInteractive,
35+
}: {
36+
projectDir: string;
37+
exp: ExpoConfig;
38+
nonInteractive: boolean;
39+
}): Promise<void> {
40+
const description = getProjectConfigDescription(projectDir);
41+
if (nonInteractive) {
42+
Log.warn(
43+
chalk`${description} is missing {bold ios.infoPlist.ITSAppUsesNonExemptEncryption} boolean. Manual configuration is required in App Store Connect before the app can be tested.`
44+
);
45+
}
46+
47+
let onlyExemptEncryption = await confirmAsync({
48+
message: `iOS app only uses standard/exempt encryption? ${chalk.dim(
49+
learnMore(
50+
'https://developer.apple.com/documentation/Security/complying-with-encryption-export-regulations'
51+
)
52+
)}`,
53+
initial: true,
54+
});
55+
56+
if (!onlyExemptEncryption) {
57+
const confirm = await confirmAsync({
58+
message: `Are you sure your app uses non-exempt encryption? Selecting 'Yes' will require annual self-classification reports for the US government.`,
59+
initial: true,
60+
});
61+
62+
if (!confirm) {
63+
Log.warn(
64+
chalk`Set {bold ios.infoPlist.ITSAppUsesNonExemptEncryption} in ${description} to release Apple builds faster.`
65+
);
66+
onlyExemptEncryption = true;
67+
}
68+
}
69+
70+
const ITSAppUsesNonExemptEncryption = !onlyExemptEncryption;
71+
72+
// Only set this value if the answer is no, this enables developers to see the more in-depth prompt in App Store Connect. They can set the value manually in the app.json to avoid the EAS prompt in subsequent builds.
73+
if (ITSAppUsesNonExemptEncryption) {
74+
Log.warn(
75+
`You'll need to manually configure the encryption status in App Store Connect before your build can be tested.`
76+
);
77+
return;
78+
}
79+
// NOTE: Is is it possible to assert that the config needs to be modifiable before building the app?
80+
const modification = await modifyConfigAsync(
81+
projectDir,
82+
{
83+
ios: {
84+
...(exp.ios ?? {}),
85+
infoPlist: {
86+
...(exp.ios?.infoPlist ?? {}),
87+
ITSAppUsesNonExemptEncryption,
88+
},
89+
},
90+
},
91+
{
92+
skipSDKVersionRequirement: true,
93+
}
94+
);
95+
96+
if (modification.type !== 'success') {
97+
Log.log();
98+
if (modification.type === 'warn') {
99+
// The project is using a dynamic config, give the user a helpful log and bail out.
100+
Log.log(chalk.yellow(modification.message));
101+
}
102+
103+
const edits = {
104+
ios: {
105+
infoPlist: {
106+
ITSAppUsesNonExemptEncryption,
107+
},
108+
},
109+
};
110+
111+
Log.log(chalk.cyan(`Add the following to ${description}:`));
112+
Log.log();
113+
Log.log(JSON.stringify(edits, null, 2));
114+
Log.log();
115+
}
116+
}

0 commit comments

Comments
 (0)