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