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