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