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