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 { env } from './env'; 20import { CommandError } from './errors'; 21import { getExpoGoIntermediateCertificateAsync } from '../api/getExpoGoIntermediateCertificate'; 22import { getProjectDevelopmentCertificateAsync } from '../api/getProjectDevelopmentCertificate'; 23import { AppQuery } from '../api/graphql/queries/AppQuery'; 24import { ensureLoggedInAsync } from '../api/user/actions'; 25import { Actor } from '../api/user/user'; 26import { AppByIdQuery, Permission } from '../graphql/generated'; 27import * as Log from '../log'; 28import { learnMore } from '../utils/link'; 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 = 196 await DevelopmentCodeSigningInfoFile.readAsync(easProjectId); 197 const validatedCodeSigningInfo = validateStoredDevelopmentExpoRootCertificateCodeSigningInfo( 198 developmentCodeSigningInfoFromFile, 199 easProjectId 200 ); 201 202 // 1. If online, ensure logged in, generate key pair and CSR, fetch and cache certificate chain for projectId 203 // (overwriting existing dev cert in case projectId changed or it has expired) 204 if (!env.EXPO_OFFLINE) { 205 try { 206 return await fetchAndCacheNewDevelopmentCodeSigningInfoAsync(easProjectId); 207 } catch (e: any) { 208 if (validatedCodeSigningInfo) { 209 Log.warn( 210 'There was an error fetching the Expo development certificate, falling back to cached certificate' 211 ); 212 return validatedCodeSigningInfo; 213 } else { 214 // need to return null here and say a message 215 throw e; 216 } 217 } 218 } 219 220 // 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 221 if (validatedCodeSigningInfo) { 222 return validatedCodeSigningInfo; 223 } 224 225 // 3. if offline, return null 226 Log.warn('Offline and no cached development certificate found, unable to sign manifest'); 227 return null; 228} 229 230/** 231 * Get the certificate configured for expo-updates for this project. 232 */ 233async function getProjectCodeSigningCertificateAsync( 234 exp: ExpoConfig, 235 privateKeyPath: string | undefined, 236 expectedKeyId: string, 237 expectedAlg: string | null 238): Promise<CodeSigningInfo | null> { 239 const codeSigningCertificatePath = exp.updates?.codeSigningCertificate; 240 if (!codeSigningCertificatePath) { 241 return null; 242 } 243 244 if (!privateKeyPath) { 245 throw new CommandError( 246 'Must specify --private-key-path argument to sign development manifest for requested code signing key' 247 ); 248 } 249 250 const codeSigningMetadata = exp.updates?.codeSigningMetadata; 251 if (!codeSigningMetadata) { 252 throw new CommandError( 253 'Must specify "codeSigningMetadata" under the "updates" field of your app config file to use EAS code signing' 254 ); 255 } 256 257 const { alg, keyid } = codeSigningMetadata; 258 if (!alg || !keyid) { 259 throw new CommandError( 260 'Must specify "keyid" and "alg" in the "codeSigningMetadata" field under the "updates" field of your app config file to use EAS code signing' 261 ); 262 } 263 264 if (expectedKeyId !== keyid) { 265 throw new CommandError(`keyid mismatch: client=${expectedKeyId}, project=${keyid}`); 266 } 267 268 if (expectedAlg && expectedAlg !== alg) { 269 throw new CommandError(`"alg" field mismatch (client=${expectedAlg}, project=${alg})`); 270 } 271 272 const { privateKeyPEM, certificatePEM } = 273 await getProjectPrivateKeyAndCertificateFromFilePathsAsync({ 274 codeSigningCertificatePath, 275 privateKeyPath, 276 }); 277 278 return { 279 keyId: keyid, 280 privateKey: privateKeyPEM, 281 certificateForPrivateKey: certificatePEM, 282 certificateChainForResponse: [], 283 scopeKey: null, 284 }; 285} 286 287async function readFileWithErrorAsync(path: string, errorMessage: string): Promise<string> { 288 try { 289 return await fs.readFile(path, 'utf8'); 290 } catch { 291 throw new CommandError(errorMessage); 292 } 293} 294 295async function getProjectPrivateKeyAndCertificateFromFilePathsAsync({ 296 codeSigningCertificatePath, 297 privateKeyPath, 298}: { 299 codeSigningCertificatePath: string; 300 privateKeyPath: string; 301}): Promise<{ privateKeyPEM: string; certificatePEM: string }> { 302 const [codeSigningCertificatePEM, privateKeyPEM] = await Promise.all([ 303 readFileWithErrorAsync( 304 codeSigningCertificatePath, 305 `Code signing certificate cannot be read from path: ${codeSigningCertificatePath}` 306 ), 307 readFileWithErrorAsync( 308 privateKeyPath, 309 `Code signing private key cannot be read from path: ${privateKeyPath}` 310 ), 311 ]); 312 313 const privateKey = convertPrivateKeyPEMToPrivateKey(privateKeyPEM); 314 const certificate = convertCertificatePEMToCertificate(codeSigningCertificatePEM); 315 validateSelfSignedCertificate(certificate, { 316 publicKey: certificate.publicKey as PKI.rsa.PublicKey, 317 privateKey, 318 }); 319 320 return { privateKeyPEM, certificatePEM: codeSigningCertificatePEM }; 321} 322 323/** 324 * Validate that the cached code signing info is still valid for the current project and 325 * that it hasn't expired. If invalid, return null. 326 */ 327function validateStoredDevelopmentExpoRootCertificateCodeSigningInfo( 328 codeSigningInfo: StoredDevelopmentExpoRootCodeSigningInfo, 329 easProjectId: string 330): CodeSigningInfo | null { 331 if (codeSigningInfo.easProjectId !== easProjectId) { 332 return null; 333 } 334 335 const { 336 privateKey: privateKeyPEM, 337 certificateChain: certificatePEMs, 338 scopeKey, 339 } = codeSigningInfo; 340 if (!privateKeyPEM || !certificatePEMs) { 341 return null; 342 } 343 344 const certificateChain = certificatePEMs.map((certificatePEM) => 345 convertCertificatePEMToCertificate(certificatePEM) 346 ); 347 348 // TODO(wschurman): maybe move to @expo/code-signing-certificates 349 const leafCertificate = certificateChain[0]; 350 const now = new Date(); 351 if (leafCertificate.validity.notBefore > now || leafCertificate.validity.notAfter < now) { 352 return null; 353 } 354 355 // TODO(wschurman): maybe do more validation, like validation of projectID and scopeKey within eas certificate extension 356 357 return { 358 keyId: 'expo-go', 359 certificateChainForResponse: certificatePEMs, 360 certificateForPrivateKey: certificatePEMs[0], 361 privateKey: privateKeyPEM, 362 scopeKey, 363 }; 364} 365 366function actorCanGetProjectDevelopmentCertificate(actor: Actor, app: AppByIdQuery['app']['byId']) { 367 const owningAccountId = app.ownerAccount.id; 368 369 const owningAccountIsActorPrimaryAccount = 370 actor.__typename === 'User' || actor.__typename === 'SSOUser' 371 ? actor.primaryAccount.id === owningAccountId 372 : false; 373 const userHasPublishPermissionForOwningAccount = !!actor.accounts 374 .find((account) => account.id === owningAccountId) 375 ?.users?.find((userPermission) => userPermission.actor.id === actor.id) 376 ?.permissions?.includes(Permission.Publish); 377 return owningAccountIsActorPrimaryAccount || userHasPublishPermissionForOwningAccount; 378} 379 380async function fetchAndCacheNewDevelopmentCodeSigningInfoAsync( 381 easProjectId: string 382): Promise<CodeSigningInfo | null> { 383 const actor = await ensureLoggedInAsync(); 384 const app = await AppQuery.byIdAsync(easProjectId); 385 if (!actorCanGetProjectDevelopmentCertificate(actor, app)) { 386 return null; 387 } 388 389 const keyPair = generateKeyPair(); 390 const keyPairPEM = convertKeyPairToPEM(keyPair); 391 const csr = generateCSR(keyPair, `Development Certificate for ${easProjectId}`); 392 const csrPEM = convertCSRToCSRPEM(csr); 393 const [developmentSigningCertificate, expoGoIntermediateCertificate] = await Promise.all([ 394 getProjectDevelopmentCertificateAsync(easProjectId, csrPEM), 395 getExpoGoIntermediateCertificateAsync(easProjectId), 396 ]); 397 398 await DevelopmentCodeSigningInfoFile.setAsync(easProjectId, { 399 easProjectId, 400 scopeKey: app.scopeKey, 401 privateKey: keyPairPEM.privateKeyPEM, 402 certificateChain: [developmentSigningCertificate, expoGoIntermediateCertificate], 403 }); 404 405 return { 406 keyId: 'expo-go', 407 certificateChainForResponse: [developmentSigningCertificate, expoGoIntermediateCertificate], 408 certificateForPrivateKey: developmentSigningCertificate, 409 privateKey: keyPairPEM.privateKeyPEM, 410 scopeKey: app.scopeKey, 411 }; 412} 413/** 414 * Generate the `expo-signature` header for a manifest and code signing info. 415 */ 416export function signManifestString( 417 stringifiedManifest: string, 418 codeSigningInfo: CodeSigningInfo 419): string { 420 const privateKey = convertPrivateKeyPEMToPrivateKey(codeSigningInfo.privateKey); 421 const certificate = convertCertificatePEMToCertificate(codeSigningInfo.certificateForPrivateKey); 422 return signBufferRSASHA256AndVerify( 423 privateKey, 424 certificate, 425 Buffer.from(stringifiedManifest, 'utf8') 426 ); 427} 428