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