xref: /expo/packages/@expo/cli/src/utils/codesigning.ts (revision 2101e951)
1import {
2  convertCertificatePEMToCertificate,
3  convertKeyPairToPEM,
4  convertCSRToCSRPEM,
5  generateKeyPair,
6  generateCSR,
7  convertPrivateKeyPEMToPrivateKey,
8  validateSelfSignedCertificate,
9  signBufferRSASHA256AndVerify,
10} from '@expo/code-signing-certificates';
11import { ExpoConfig } from '@expo/config';
12import { getExpoHomeDirectory } from '@expo/config/build/getUserState';
13import JsonFile, { JSONObject } from '@expo/json-file';
14import { promises as fs } from 'fs';
15import { pki as PKI } from 'node-forge';
16import path from 'path';
17import { Dictionary, parseDictionary } from 'structured-headers';
18
19import { getExpoGoIntermediateCertificateAsync } from '../api/getExpoGoIntermediateCertificate';
20import { getProjectDevelopmentCertificateAsync } from '../api/getProjectDevelopmentCertificate';
21import { AppQuery } from '../api/graphql/queries/AppQuery';
22import { APISettings } from '../api/settings';
23import { ensureLoggedInAsync } from '../api/user/actions';
24import { Actor } from '../api/user/user';
25import { AppByIdQuery, Permission } from '../graphql/generated';
26import * as Log from '../log';
27import { CommandError } from './errors';
28
29export type CodeSigningInfo = {
30  keyId: string;
31  privateKey: string;
32  certificateForPrivateKey: string;
33  /**
34   * Chain of certificates to serve in the manifest multipart body "certificate_chain" part.
35   * The leaf certificate must be the 0th element of the array, followed by any intermediate certificates
36   * necessary to evaluate the chain of trust ending in the implicitly trusted root certificate embedded in
37   * the client.
38   *
39   * An empty array indicates that there is no need to serve the certificate chain in the multipart response.
40   */
41  certificateChainForResponse: string[];
42  /**
43   * Scope key cached for the project when certificate is development Expo Go code signing.
44   * For project-specific code signing (keyId == the project's generated keyId) this is undefined.
45   */
46  scopeKey: string | null;
47};
48
49type StoredDevelopmentExpoRootCodeSigningInfo = {
50  easProjectId: string | null;
51  scopeKey: string | null;
52  privateKey: string | null;
53  certificateChain: string[] | null;
54};
55const DEVELOPMENT_CODE_SIGNING_SETTINGS_FILE_NAME = 'development-code-signing-settings-2.json';
56
57export function getDevelopmentCodeSigningDirectory(): string {
58  return path.join(getExpoHomeDirectory(), 'codesigning');
59}
60
61function getProjectDevelopmentCodeSigningInfoFile<T extends JSONObject>(defaults: T) {
62  function getFile(easProjectId: string): JsonFile<T> {
63    const filePath = path.join(
64      getDevelopmentCodeSigningDirectory(),
65      easProjectId,
66      DEVELOPMENT_CODE_SIGNING_SETTINGS_FILE_NAME
67    );
68    return new JsonFile<T>(filePath);
69  }
70
71  async function readAsync(easProjectId: string): Promise<T> {
72    let projectSettings;
73    try {
74      projectSettings = await getFile(easProjectId).readAsync();
75    } catch {
76      projectSettings = await getFile(easProjectId).writeAsync(defaults, { ensureDir: true });
77    }
78    // Set defaults for any missing fields
79    return { ...defaults, ...projectSettings };
80  }
81
82  async function setAsync(easProjectId: string, json: Partial<T>): Promise<T> {
83    try {
84      return await getFile(easProjectId).mergeAsync(json, {
85        cantReadFileDefault: defaults,
86      });
87    } catch {
88      return await getFile(easProjectId).writeAsync(
89        {
90          ...defaults,
91          ...json,
92        },
93        { ensureDir: true }
94      );
95    }
96  }
97
98  return {
99    getFile,
100    readAsync,
101    setAsync,
102  };
103}
104
105export const DevelopmentCodeSigningInfoFile =
106  getProjectDevelopmentCodeSigningInfoFile<StoredDevelopmentExpoRootCodeSigningInfo>({
107    easProjectId: null,
108    scopeKey: null,
109    privateKey: null,
110    certificateChain: null,
111  });
112
113/**
114 * Get info necessary to generate a response `expo-signature` header given a project and incoming request `expo-expect-signature` header.
115 * This only knows how to serve two code signing keyids:
116 * - `expo-root` indicates that it should use a development certificate in the `expo-root` chain. See {@link getExpoRootDevelopmentCodeSigningInfoAsync}
117 * - <developer's expo-updates keyid> indicates that it should sign with the configured certificate. See {@link getProjectCodeSigningCertificateAsync}
118 */
119export async function getCodeSigningInfoAsync(
120  exp: ExpoConfig,
121  expectSignatureHeader: string | null,
122  privateKeyPath: string | undefined
123): Promise<CodeSigningInfo | null> {
124  if (!expectSignatureHeader) {
125    return null;
126  }
127
128  let parsedExpectSignature: Dictionary;
129  try {
130    parsedExpectSignature = parseDictionary(expectSignatureHeader);
131  } catch {
132    throw new CommandError('Invalid value for expo-expect-signature header');
133  }
134
135  const expectedKeyIdOuter = parsedExpectSignature.get('keyid');
136  if (!expectedKeyIdOuter) {
137    throw new CommandError('keyid not present in expo-expect-signature header');
138  }
139
140  const expectedKeyId = expectedKeyIdOuter[0];
141  if (typeof expectedKeyId !== 'string') {
142    throw new CommandError(
143      `Invalid value for keyid in expo-expect-signature header: ${expectedKeyId}`
144    );
145  }
146
147  let expectedAlg: string | null = null;
148  const expectedAlgOuter = parsedExpectSignature.get('alg');
149  if (expectedAlgOuter) {
150    const expectedAlgTemp = expectedAlgOuter[0];
151    if (typeof expectedAlgTemp !== 'string') {
152      throw new CommandError('Invalid value for alg in expo-expect-signature header');
153    }
154    expectedAlg = expectedAlgTemp;
155  }
156
157  if (expectedKeyId === 'expo-root') {
158    return await getExpoRootDevelopmentCodeSigningInfoAsync(exp);
159  } else if (expectedKeyId === 'expo-go') {
160    throw new CommandError(
161      'Invalid certificate requested: cannot sign with embedded keyid=expo-go key'
162    );
163  } else {
164    return await getProjectCodeSigningCertificateAsync(
165      exp,
166      privateKeyPath,
167      expectedKeyId,
168      expectedAlg
169    );
170  }
171}
172
173/**
174 * Get a development code signing certificate for the expo-root -> expo-go -> (development certificate) certificate chain.
175 * This requires the user be logged in and online, otherwise try to use the cached development certificate.
176 */
177async function getExpoRootDevelopmentCodeSigningInfoAsync(
178  exp: ExpoConfig
179): Promise<CodeSigningInfo | null> {
180  const easProjectId = exp.extra?.eas?.projectId;
181  // can't check for scope key validity since scope key is derived on the server from projectId and we may be offline.
182  // we rely upon the client certificate check to validate the scope key
183  if (!easProjectId) {
184    Log.warn('No project ID specified in app.json, unable to sign manifest');
185    return null;
186  }
187
188  const developmentCodeSigningInfoFromFile = await DevelopmentCodeSigningInfoFile.readAsync(
189    easProjectId
190  );
191  const validatedCodeSigningInfo = validateStoredDevelopmentExpoRootCertificateCodeSigningInfo(
192    developmentCodeSigningInfoFromFile,
193    easProjectId
194  );
195
196  // 1. If online, ensure logged in, generate key pair and CSR, fetch and cache certificate chain for projectId
197  //    (overwriting existing dev cert in case projectId changed or it has expired)
198  if (!APISettings.isOffline) {
199    try {
200      return await fetchAndCacheNewDevelopmentCodeSigningInfoAsync(easProjectId);
201    } catch (e) {
202      if (validatedCodeSigningInfo) {
203        Log.warn(
204          'There was an error fetching the Expo development certificate, falling back to cached certificate'
205        );
206        return validatedCodeSigningInfo;
207      } else {
208        // need to return null here and say a message
209        throw e;
210      }
211    }
212  }
213
214  // 2. check for cached cert/private key matching projectId and scopeKey of project, if found and valid return private key and cert chain including expo-go cert
215  if (validatedCodeSigningInfo) {
216    return validatedCodeSigningInfo;
217  }
218
219  // 3. if offline, return null
220  Log.warn('Offline and no cached development certificate found, unable to sign manifest');
221  return null;
222}
223
224/**
225 * Get the certificate configured for expo-updates for this project.
226 */
227async function getProjectCodeSigningCertificateAsync(
228  exp: ExpoConfig,
229  privateKeyPath: string | undefined,
230  expectedKeyId: string,
231  expectedAlg: string | null
232): Promise<CodeSigningInfo | null> {
233  const codeSigningCertificatePath = exp.updates?.codeSigningCertificate;
234  if (!codeSigningCertificatePath) {
235    return null;
236  }
237
238  if (!privateKeyPath) {
239    throw new CommandError(
240      'Must specify --private-key-path argument to sign development manifest for requested code signing key'
241    );
242  }
243
244  const codeSigningMetadata = exp.updates?.codeSigningMetadata;
245  if (!codeSigningMetadata) {
246    throw new CommandError(
247      'Must specify "codeSigningMetadata" under the "updates" field of your app config file to use EAS code signing'
248    );
249  }
250
251  const { alg, keyid } = codeSigningMetadata;
252  if (!alg || !keyid) {
253    throw new CommandError(
254      'Must specify "keyid" and "alg" in the "codeSigningMetadata" field under the "updates" field of your app config file to use EAS code signing'
255    );
256  }
257
258  if (expectedKeyId !== keyid) {
259    throw new CommandError(`keyid mismatch: client=${expectedKeyId}, project=${keyid}`);
260  }
261
262  if (expectedAlg && expectedAlg !== alg) {
263    throw new CommandError(`"alg" field mismatch (client=${expectedAlg}, project=${alg})`);
264  }
265
266  const { privateKeyPEM, certificatePEM } =
267    await getProjectPrivateKeyAndCertificateFromFilePathsAsync({
268      codeSigningCertificatePath,
269      privateKeyPath,
270    });
271
272  return {
273    keyId: keyid,
274    privateKey: privateKeyPEM,
275    certificateForPrivateKey: certificatePEM,
276    certificateChainForResponse: [],
277    scopeKey: null,
278  };
279}
280
281async function readFileWithErrorAsync(path: string, errorMessage: string): Promise<string> {
282  try {
283    return await fs.readFile(path, 'utf8');
284  } catch {
285    throw new CommandError(errorMessage);
286  }
287}
288
289async function getProjectPrivateKeyAndCertificateFromFilePathsAsync({
290  codeSigningCertificatePath,
291  privateKeyPath,
292}: {
293  codeSigningCertificatePath: string;
294  privateKeyPath: string;
295}): Promise<{ privateKeyPEM: string; certificatePEM: string }> {
296  const [codeSigningCertificatePEM, privateKeyPEM] = await Promise.all([
297    readFileWithErrorAsync(
298      codeSigningCertificatePath,
299      `Code signing certificate cannot be read from path: ${codeSigningCertificatePath}`
300    ),
301    readFileWithErrorAsync(
302      privateKeyPath,
303      `Code signing private key cannot be read from path: ${privateKeyPath}`
304    ),
305  ]);
306
307  const privateKey = convertPrivateKeyPEMToPrivateKey(privateKeyPEM);
308  const certificate = convertCertificatePEMToCertificate(codeSigningCertificatePEM);
309  validateSelfSignedCertificate(certificate, {
310    publicKey: certificate.publicKey as PKI.rsa.PublicKey,
311    privateKey,
312  });
313
314  return { privateKeyPEM, certificatePEM: codeSigningCertificatePEM };
315}
316
317/**
318 * Validate that the cached code signing info is still valid for the current project and
319 * that it hasn't expired. If invalid, return null.
320 */
321function validateStoredDevelopmentExpoRootCertificateCodeSigningInfo(
322  codeSigningInfo: StoredDevelopmentExpoRootCodeSigningInfo,
323  easProjectId: string
324): CodeSigningInfo | null {
325  if (codeSigningInfo.easProjectId !== easProjectId) {
326    return null;
327  }
328
329  const {
330    privateKey: privateKeyPEM,
331    certificateChain: certificatePEMs,
332    scopeKey,
333  } = codeSigningInfo;
334  if (!privateKeyPEM || !certificatePEMs) {
335    return null;
336  }
337
338  const certificateChain = certificatePEMs.map((certificatePEM) =>
339    convertCertificatePEMToCertificate(certificatePEM)
340  );
341
342  // TODO(wschurman): maybe move to @expo/code-signing-certificates
343  const leafCertificate = certificateChain[0];
344  const now = new Date();
345  if (leafCertificate.validity.notBefore > now || leafCertificate.validity.notAfter < now) {
346    return null;
347  }
348
349  // TODO(wschurman): maybe do more validation, like validation of projectID and scopeKey within eas certificate extension
350
351  return {
352    keyId: 'expo-go',
353    certificateChainForResponse: certificatePEMs,
354    certificateForPrivateKey: certificatePEMs[0],
355    privateKey: privateKeyPEM,
356    scopeKey,
357  };
358}
359
360function actorCanGetProjectDevelopmentCertificate(actor: Actor, app: AppByIdQuery['app']['byId']) {
361  const owningAccountId = app.ownerAccount.id;
362
363  const owningAccountIsActorPrimaryAccount =
364    actor.__typename === 'User' || actor.__typename === 'SSOUser'
365      ? actor.primaryAccount.id === owningAccountId
366      : false;
367  const userHasPublishPermissionForOwningAccount = !!actor.accounts
368    .find((account) => account.id === owningAccountId)
369    ?.users?.find((userPermission) => userPermission.actor.id === actor.id)
370    ?.permissions?.includes(Permission.Publish);
371  return owningAccountIsActorPrimaryAccount || userHasPublishPermissionForOwningAccount;
372}
373
374async function fetchAndCacheNewDevelopmentCodeSigningInfoAsync(
375  easProjectId: string
376): Promise<CodeSigningInfo | null> {
377  const actor = await ensureLoggedInAsync();
378  const app = await AppQuery.byIdAsync(easProjectId);
379  if (!actorCanGetProjectDevelopmentCertificate(actor, app)) {
380    return null;
381  }
382
383  const keyPair = generateKeyPair();
384  const keyPairPEM = convertKeyPairToPEM(keyPair);
385  const csr = generateCSR(keyPair, `Development Certificate for ${easProjectId}`);
386  const csrPEM = convertCSRToCSRPEM(csr);
387  const [developmentSigningCertificate, expoGoIntermediateCertificate] = await Promise.all([
388    getProjectDevelopmentCertificateAsync(easProjectId, csrPEM),
389    getExpoGoIntermediateCertificateAsync(easProjectId),
390  ]);
391
392  await DevelopmentCodeSigningInfoFile.setAsync(easProjectId, {
393    easProjectId,
394    scopeKey: app.scopeKey,
395    privateKey: keyPairPEM.privateKeyPEM,
396    certificateChain: [developmentSigningCertificate, expoGoIntermediateCertificate],
397  });
398
399  return {
400    keyId: 'expo-go',
401    certificateChainForResponse: [developmentSigningCertificate, expoGoIntermediateCertificate],
402    certificateForPrivateKey: developmentSigningCertificate,
403    privateKey: keyPairPEM.privateKeyPEM,
404    scopeKey: app.scopeKey,
405  };
406}
407/**
408 * Generate the `expo-signature` header for a manifest and code signing info.
409 */
410export function signManifestString(
411  stringifiedManifest: string,
412  codeSigningInfo: CodeSigningInfo
413): string {
414  const privateKey = convertPrivateKeyPEMToPrivateKey(codeSigningInfo.privateKey);
415  const certificate = convertCertificatePEMToCertificate(codeSigningInfo.certificateForPrivateKey);
416  return signBufferRSASHA256AndVerify(
417    privateKey,
418    certificate,
419    Buffer.from(stringifiedManifest, 'utf8')
420  );
421}
422