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