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 // 1. If online, ensure logged in, generate key pair and CSR, fetch and cache certificate chain for projectId 177 // (overwriting existing dev cert in case projectId changed or it has expired) 178 if (!APISettings.isOffline) { 179 return await fetchAndCacheNewDevelopmentCodeSigningInfoAsync(easProjectId); 180 } 181 182 // 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 183 const developmentCodeSigningInfoFromFile = await DevelopmentCodeSigningInfoFile.readAsync( 184 easProjectId 185 ); 186 const validatedCodeSigningInfo = validateStoredDevelopmentExpoRootCertificateCodeSigningInfo( 187 developmentCodeSigningInfoFromFile, 188 easProjectId 189 ); 190 if (validatedCodeSigningInfo) { 191 return validatedCodeSigningInfo; 192 } 193 194 // 3. if offline, return null 195 Log.warn('Offline and no cached development certificate found, unable to sign manifest'); 196 return null; 197} 198 199/** 200 * Get the certificate configured for expo-updates for this project. 201 */ 202async function getProjectCodeSigningCertificateAsync( 203 exp: ExpoConfig, 204 privateKeyPath: string | undefined, 205 expectedKeyId: string, 206 expectedAlg: string | null 207): Promise<CodeSigningInfo | null> { 208 const codeSigningCertificatePath = exp.updates?.codeSigningCertificate; 209 if (!codeSigningCertificatePath) { 210 return null; 211 } 212 213 if (!privateKeyPath) { 214 privateKeyPath = path.join(path.dirname(codeSigningCertificatePath), 'private-key.pem'); 215 } 216 217 const codeSigningMetadata = exp.updates?.codeSigningMetadata; 218 if (!codeSigningMetadata) { 219 throw new CommandError( 220 'Must specify "codeSigningMetadata" under the "updates" field of your app config file to use EAS code signing' 221 ); 222 } 223 224 const { alg, keyid } = codeSigningMetadata; 225 if (!alg || !keyid) { 226 throw new CommandError( 227 'Must specify "keyid" and "alg" in the "codeSigningMetadata" field under the "updates" field of your app config file to use EAS code signing' 228 ); 229 } 230 231 if (expectedKeyId !== keyid) { 232 throw new CommandError(`keyid mismatch: client=${expectedKeyId}, project=${keyid}`); 233 } 234 235 if (expectedAlg && expectedAlg !== alg) { 236 throw new CommandError(`"alg" field mismatch (client=${expectedAlg}, project=${alg})`); 237 } 238 239 const { privateKeyPEM, certificatePEM } = 240 await getProjectPrivateKeyAndCertificateFromFilePathsAsync({ 241 codeSigningCertificatePath, 242 privateKeyPath, 243 }); 244 245 return { 246 privateKey: privateKeyPEM, 247 certificateForPrivateKey: certificatePEM, 248 certificateChainForResponse: [], 249 }; 250} 251 252async function readFileWithErrorAsync(path: string, errorMessage: string): Promise<string> { 253 try { 254 return await fs.readFile(path, 'utf8'); 255 } catch { 256 throw new CommandError(errorMessage); 257 } 258} 259 260async function getProjectPrivateKeyAndCertificateFromFilePathsAsync({ 261 codeSigningCertificatePath, 262 privateKeyPath, 263}: { 264 codeSigningCertificatePath: string; 265 privateKeyPath: string; 266}): Promise<{ privateKeyPEM: string; certificatePEM: string }> { 267 const [codeSigningCertificatePEM, privateKeyPEM] = await Promise.all([ 268 readFileWithErrorAsync( 269 codeSigningCertificatePath, 270 `Code signing certificate cannot be read from path: ${codeSigningCertificatePath}` 271 ), 272 readFileWithErrorAsync( 273 privateKeyPath, 274 `Code signing private key cannot be read from path: ${privateKeyPath}` 275 ), 276 ]); 277 278 const privateKey = convertPrivateKeyPEMToPrivateKey(privateKeyPEM); 279 const certificate = convertCertificatePEMToCertificate(codeSigningCertificatePEM); 280 validateSelfSignedCertificate(certificate, { 281 publicKey: certificate.publicKey as PKI.rsa.PublicKey, 282 privateKey, 283 }); 284 285 return { privateKeyPEM, certificatePEM: codeSigningCertificatePEM }; 286} 287 288/** 289 * Validate that the cached code signing info is still valid for the current project and 290 * that it hasn't expired. If invalid, return null. 291 */ 292function validateStoredDevelopmentExpoRootCertificateCodeSigningInfo( 293 codeSigningInfo: StoredDevelopmentExpoRootCodeSigningInfo, 294 easProjectId: string 295): CodeSigningInfo | null { 296 if (codeSigningInfo.easProjectId !== easProjectId) { 297 return null; 298 } 299 300 const { privateKey: privateKeyPEM, certificateChain: certificatePEMs } = codeSigningInfo; 301 if (!privateKeyPEM || !certificatePEMs) { 302 return null; 303 } 304 305 const certificateChain = certificatePEMs.map((certificatePEM) => 306 convertCertificatePEMToCertificate(certificatePEM) 307 ); 308 309 // TODO(wschurman): maybe move to @expo/code-signing-certificates 310 const leafCertificate = certificateChain[0]; 311 const now = new Date(); 312 if (leafCertificate.validity.notBefore > now || leafCertificate.validity.notAfter < now) { 313 return null; 314 } 315 316 // TODO(wschurman): maybe do more validation 317 318 return { 319 certificateChainForResponse: certificatePEMs, 320 certificateForPrivateKey: certificatePEMs[0], 321 privateKey: privateKeyPEM, 322 }; 323} 324 325async function fetchAndCacheNewDevelopmentCodeSigningInfoAsync( 326 easProjectId: string 327): Promise<CodeSigningInfo> { 328 const keyPair = generateKeyPair(); 329 const keyPairPEM = convertKeyPairToPEM(keyPair); 330 const csr = generateCSR(keyPair, `Development Certificate for ${easProjectId}`); 331 const csrPEM = convertCSRToCSRPEM(csr); 332 const [developmentSigningCertificate, expoGoIntermediateCertificate] = await Promise.all([ 333 getProjectDevelopmentCertificateAsync(easProjectId, csrPEM), 334 getExpoGoIntermediateCertificateAsync(easProjectId), 335 ]); 336 337 await DevelopmentCodeSigningInfoFile.setAsync(easProjectId, { 338 easProjectId, 339 privateKey: keyPairPEM.privateKeyPEM, 340 certificateChain: [developmentSigningCertificate, expoGoIntermediateCertificate], 341 }); 342 343 return { 344 certificateChainForResponse: [developmentSigningCertificate, expoGoIntermediateCertificate], 345 certificateForPrivateKey: developmentSigningCertificate, 346 privateKey: keyPairPEM.privateKeyPEM, 347 }; 348} 349/** 350 * Generate the `expo-signature` header for a manifest and code signing info. 351 */ 352export function signManifestString( 353 stringifiedManifest: string, 354 codeSigningInfo: CodeSigningInfo 355): string { 356 const privateKey = convertPrivateKeyPEMToPrivateKey(codeSigningInfo.privateKey); 357 const certificate = convertCertificatePEMToCertificate(codeSigningInfo.certificateForPrivateKey); 358 return signStringRSASHA256AndVerify(privateKey, certificate, stringifiedManifest); 359} 360