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