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