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