1import { vol } from 'memfs';
2
3import { asMock } from '../../__tests__/asMock';
4import { getProjectDevelopmentCertificateAsync } from '../../api/getProjectDevelopmentCertificate';
5import { getUserAsync } from '../../api/user/user';
6import { getCodeSigningInfoAsync, signManifestString } from '../codesigning';
7import { mockExpoRootChain, mockSelfSigned } from './fixtures/certificates';
8
9jest.mock('../../api/user/user');
10jest.mock('../../api/graphql/queries/AppQuery', () => ({
11  AppQuery: {
12    byIdAsync: jest.fn(async () => ({
13      id: 'blah',
14      scopeKey: 'scope-key',
15      ownerAccount: {
16        id: 'blah-account',
17      },
18    })),
19  },
20}));
21jest.mock('../../log');
22jest.mock('@expo/code-signing-certificates', () => ({
23  ...(jest.requireActual(
24    '@expo/code-signing-certificates'
25  ) as typeof import('@expo/code-signing-certificates')),
26  generateKeyPair: jest.fn(() =>
27    (
28      jest.requireActual(
29        '@expo/code-signing-certificates'
30      ) as typeof import('@expo/code-signing-certificates')
31    ).convertKeyPairPEMToKeyPair({
32      publicKeyPEM: mockExpoRootChain.publicKeyPEM,
33      privateKeyPEM: mockExpoRootChain.privateKeyPEM,
34    })
35  ),
36}));
37jest.mock('../../api/getProjectDevelopmentCertificate', () => ({
38  getProjectDevelopmentCertificateAsync: jest.fn(() => mockExpoRootChain.developmentCertificate),
39}));
40jest.mock('../../api/getExpoGoIntermediateCertificate', () => ({
41  getExpoGoIntermediateCertificateAsync: jest.fn(
42    () => mockExpoRootChain.expoGoIntermediateCertificate
43  ),
44}));
45
46beforeEach(() => {
47  vol.reset();
48
49  asMock(getUserAsync).mockImplementation(async () => ({
50    __typename: 'User',
51    id: 'userwat',
52    username: 'wat',
53    primaryAccount: { id: 'blah-account' },
54    accounts: [],
55  }));
56});
57
58describe(getCodeSigningInfoAsync, () => {
59  beforeEach(() => {
60    delete process.env.EXPO_OFFLINE;
61  });
62  it('returns null when no expo-expect-signature header is requested', async () => {
63    await expect(getCodeSigningInfoAsync({} as any, null, undefined)).resolves.toBeNull();
64  });
65
66  it('throws when expo-expect-signature header has invalid format', async () => {
67    await expect(getCodeSigningInfoAsync({} as any, 'hello', undefined)).rejects.toThrowError(
68      'keyid not present in expo-expect-signature header'
69    );
70    await expect(getCodeSigningInfoAsync({} as any, 'keyid=1', undefined)).rejects.toThrowError(
71      'Invalid value for keyid in expo-expect-signature header: 1'
72    );
73    await expect(
74      getCodeSigningInfoAsync({} as any, 'keyid="hello", alg=1', undefined)
75    ).rejects.toThrowError('Invalid value for alg in expo-expect-signature header');
76  });
77
78  describe('expo-root keyid requested', () => {
79    describe('online', () => {
80      beforeEach(() => {
81        delete process.env.EXPO_OFFLINE;
82      });
83      afterAll(() => {
84        delete process.env.EXPO_OFFLINE;
85      });
86
87      it('normal case gets a development certificate', async () => {
88        const result = await getCodeSigningInfoAsync(
89          { extra: { eas: { projectId: 'testprojectid' } } } as any,
90          'keyid="expo-root", alg="rsa-v1_5-sha256"',
91          undefined
92        );
93        expect(result).toMatchSnapshot();
94      });
95
96      it('requires easProjectId to be configured', async () => {
97        const result = await getCodeSigningInfoAsync(
98          { extra: { eas: {} } } as any,
99          'keyid="expo-root", alg="rsa-v1_5-sha256"',
100          undefined
101        );
102        expect(result).toBeNull();
103      });
104
105      it('falls back to cached when there is a network error', async () => {
106        const result = await getCodeSigningInfoAsync(
107          { extra: { eas: { projectId: 'testprojectid' } } } as any,
108          'keyid="expo-root", alg="rsa-v1_5-sha256"',
109          undefined
110        );
111
112        asMock(getProjectDevelopmentCertificateAsync).mockImplementationOnce(
113          async (): Promise<string> => {
114            throw Error('wat');
115          }
116        );
117
118        const result2 = await getCodeSigningInfoAsync(
119          { extra: { eas: { projectId: 'testprojectid' } } } as any,
120          'keyid="expo-root", alg="rsa-v1_5-sha256"',
121          undefined
122        );
123        expect(result2).toEqual(result);
124      });
125
126      it('throws when it tried to falls back to cached when there is a network error but no cached value exists', async () => {
127        asMock(getProjectDevelopmentCertificateAsync).mockImplementationOnce(
128          async (): Promise<string> => {
129            throw Error('wat');
130          }
131        );
132
133        await expect(
134          getCodeSigningInfoAsync(
135            { extra: { eas: { projectId: 'testprojectid' } } } as any,
136            'keyid="expo-root", alg="rsa-v1_5-sha256"',
137            undefined
138          )
139        ).rejects.toThrowError('wat');
140      });
141
142      it('falls back to cached when offline', async () => {
143        const result = await getCodeSigningInfoAsync(
144          { extra: { eas: { projectId: 'testprojectid' } } } as any,
145          'keyid="expo-root", alg="rsa-v1_5-sha256"',
146          undefined
147        );
148        process.env.EXPO_OFFLINE = '1';
149        const result2 = await getCodeSigningInfoAsync(
150          { extra: { eas: { projectId: 'testprojectid' } } } as any,
151          'keyid="expo-root", alg="rsa-v1_5-sha256"',
152          undefined
153        );
154        expect(result2).toEqual(result);
155      });
156    });
157  });
158
159  describe('expo-go keyid requested', () => {
160    it('throws', async () => {
161      await expect(
162        getCodeSigningInfoAsync({} as any, 'keyid="expo-go"', undefined)
163      ).rejects.toThrowError(
164        'Invalid certificate requested: cannot sign with embedded keyid=expo-go key'
165      );
166    });
167  });
168
169  describe('non expo-root certificate keyid requested', () => {
170    it('normal case gets the configured certificate', async () => {
171      vol.fromJSON({
172        'certs/cert.pem': mockSelfSigned.certificate,
173        'keys/private-key.pem': mockSelfSigned.privateKey,
174      });
175
176      const result = await getCodeSigningInfoAsync(
177        {
178          updates: {
179            codeSigningCertificate: 'certs/cert.pem',
180            codeSigningMetadata: { keyid: 'test', alg: 'rsa-v1_5-sha256' },
181          },
182        } as any,
183        'keyid="test", alg="rsa-v1_5-sha256"',
184        'keys/private-key.pem'
185      );
186      expect(result).toMatchSnapshot();
187    });
188
189    it('throws when private key path is not supplied', async () => {
190      await expect(
191        getCodeSigningInfoAsync(
192          {
193            updates: { codeSigningCertificate: 'certs/cert.pem' },
194          } as any,
195          'keyid="test", alg="rsa-v1_5-sha256"',
196          undefined
197        )
198      ).rejects.toThrowError(
199        'Must specify --private-key-path argument to sign development manifest for requested code signing key'
200      );
201    });
202
203    it('throws when it cannot generate the requested keyid due to no code signing configuration in app.json', async () => {
204      await expect(
205        getCodeSigningInfoAsync(
206          {
207            updates: { codeSigningCertificate: 'certs/cert.pem' },
208          } as any,
209          'keyid="test", alg="rsa-v1_5-sha256"',
210          'keys/private-key.pem'
211        )
212      ).rejects.toThrowError(
213        'Must specify "codeSigningMetadata" under the "updates" field of your app config file to use EAS code signing'
214      );
215    });
216
217    it('throws when it cannot generate the requested keyid due to configured keyid or alg mismatch', async () => {
218      await expect(
219        getCodeSigningInfoAsync(
220          {
221            updates: {
222              codeSigningCertificate: 'certs/cert.pem',
223              codeSigningMetadata: { keyid: 'test2', alg: 'rsa-v1_5-sha256' },
224            },
225          } as any,
226          'keyid="test", alg="rsa-v1_5-sha256"',
227          'keys/private-key.pem'
228        )
229      ).rejects.toThrowError('keyid mismatch: client=test, project=test2');
230
231      await expect(
232        getCodeSigningInfoAsync(
233          {
234            updates: {
235              codeSigningCertificate: 'certs/cert.pem',
236              codeSigningMetadata: { keyid: 'test', alg: 'fake' },
237            },
238          } as any,
239          'keyid="test", alg="fake2"',
240          'keys/private-key.pem'
241        )
242      ).rejects.toThrowError('"alg" field mismatch (client=fake2, project=fake)');
243    });
244
245    it('throws when it cannot load configured code signing info', async () => {
246      await expect(
247        getCodeSigningInfoAsync(
248          {
249            updates: {
250              codeSigningCertificate: 'certs/cert.pem',
251              codeSigningMetadata: { keyid: 'test', alg: 'rsa-v1_5-sha256' },
252            },
253          } as any,
254          'keyid="test", alg="rsa-v1_5-sha256"',
255          'keys/private-key.pem'
256        )
257      ).rejects.toThrowError('Code signing certificate cannot be read from path: certs/cert.pem');
258    });
259  });
260});
261
262describe(signManifestString, () => {
263  beforeEach(() => {
264    delete process.env.EXPO_OFFLINE;
265  });
266  it('generates signature', () => {
267    expect(
268      signManifestString('hello', {
269        keyId: 'test',
270        certificateChainForResponse: [],
271        certificateForPrivateKey: mockSelfSigned.certificate,
272        privateKey: mockSelfSigned.privateKey,
273        scopeKey: null,
274      })
275    ).toMatchSnapshot();
276  });
277  it('validates generated signature against certificate', () => {
278    expect(() =>
279      signManifestString('hello', {
280        keyId: 'test',
281        certificateChainForResponse: [],
282        certificateForPrivateKey: '',
283        privateKey: mockSelfSigned.privateKey,
284        scopeKey: null,
285      })
286    ).toThrowError('Invalid PEM formatted message.');
287  });
288});
289