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