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