Expo Secrets 管理指南:官方客户端密钥的存储边界与构建期注入机制
【免费下载链接】expoAn open-source framework for making universal native apps with React. Expo runs on Android, iOS, and the web.项目地址: https://gitcode.com/GitHub_Trending/ex/expo
secrets/是 Expo 开源仓库中专供 Expo 团队内部使用的密钥目录,存放用于配置官方 Expo Go 客户端、连接 Expo 自有服务(Firebase、Google Maps、Analytics 等)的 API Key 等数据。本文基于 secrets/README.md 展开,结合仓库源码详解这些密钥的安全边界、Google Cloud Secret Manager 存取工作流,以及它们如何在 Android / iOS 构建期通过模板替换机制注入到官方客户端中——同时说明为什么 Expo 软件本身完全不依赖这些密钥,普通开发者也不需要它们。
一、secrets 目录的定位:仅供 Expo 团队,软件本身不依赖
按照 secrets/README.md 的说明,这个目录只对 Expo 团队有意义。它包含两类数据:
- 真正属于机密、但暴露后影响可控的凭据;
- 并非机密、却只应出现在官方发布版本中的数据,例如客户端 API Key——防止它们在开发者自己的构建中被意外带入。
关键事实是:Expo 的软件(SDK、CLI、模板)离开这些密钥依然可以正常工作。这些密钥的唯一用途是配置官方 Expo Go 客户端并连接 Expo 自用的服务。这一点从仓库的构建脚本可以得到印证:所有消费secrets/的逻辑都存在一个公开的"兜底"路径(详见下文"源码级验证"部分),没有解密密钥时构建会静默回退到公开占位值,而不是失败。
从源码结构看,secrets/目录在公开仓库中只有一个 secrets/README.md 文件,真实的keys.json等密钥文件不会出现在仓库中——这与文档中"解密后的密钥被 gitignore、不会被提交"的说明一致。
二、安全红线:纵深防御,严禁存放高危凭据
secrets/README.md对"什么能放进这个目录"给出了非常明确的安全边界:
不要将特别敏感、难以撤销的凭据(例如 Android keystore)放入本仓库或 CI,即使它们已被加密。
这是典型的**纵深防御(defense in depth)**原则。即使这些密钥不幸泄露,也只允许"不会造成重大损害或大量额外工作"的后果被触发;而 Android keystore 这类凭据一旦暴露,意味着应用签名体系被攻破,需要重新签名、更换身份、影响所有已发布版本,代价极高,因此必须排除在仓库之外。
由此可以总结出 Expo 团队选择密钥的取舍标准:
| 可以放入 | 禁止放入 |
|---|---|
| 客户端 API Key(泄露影响可控) | Android keystore / 签名凭据 |
| 仅用于官方发布版的非机密数据 | 难以撤销的高危凭据 |
| 服务配置类 Key | 即便是加密形式的高危凭据 |
密钥的权威存储介质是Google Cloud Secret Manager,本地只保留按需同步的副本——这本身也是纵深防御的一环:仓库/CI 中不长期驻留密钥数据,只在使用时解密到本地。
三、密钥存取工作流:解锁与锁定
解锁(./bin/unlock)
在仓库根目录运行./bin/unlock,即可从 Google Cloud Secret Manager 拉取并解密密钥到本地机器。
前置条件:
- 安装 Google Cloud SDK(
brew install google-cloud-sdk); - 通过
gcloud auth login完成身份认证; - 设置项目:
gcloud config set project exponentjs; - 拥有对
exponentjs项目、具备services-secrets-accessor权限的访问权——需要时可在 Google Cloud Console 的 IAM/管理员权限申请页面(对应项目exponentjs)提交访问申请。
解锁后的状态:密钥会以明文形式落在本地的secrets/目录中,该目录已被 gitignore,不会被提交到仓库。也就是说,任何团队成员只要拥有相应权限,随时可以本地解锁、参与官方客户端构建,但密钥永远不会进入版本历史。
锁定(./bin/lock)
在仓库根目录运行./bin/lock,即可删除本地secrets/目录中的密钥文件,完成"本地不留存敏感数据"的收尾。典型的操作节奏是:需要构建官方客户端时解锁 → 构建完成后立即锁定。
说明:
./bin/unlock与./bin/lock属于 Expo 团队内部维护的工具脚本,不随公开仓库分发(公开仓库根目录下不存在bin/目录),公开仓库中实际可见的只有 secrets/README.md 这份说明文档。
四、源码级验证:密钥在构建期如何被消费
secrets/README.md只描述了密钥的存取流程,而仓库源码完整展示了这些密钥的消费链路。理解这条链路,才能明白为什么"没有密钥也能构建"。
4.1 统一读取:解密密钥优先,公开占位值兜底
Android 侧的动态宏生成逻辑位于 tools/src/dynamic-macros/generateDynamicMacros.ts。其核心函数getTemplateSubstitutionsFromSecrets的读取顺序是:
- 优先读取
secrets/keys.json(解密后的真实密钥); - 如果读取失败(例如没有解密密钥),打印提示
"You don't have access to decrypted secrets. Falling back to template-files/keys.json.",然后回退到 template-files/keys.json(公开的占位值); - 随后还会尝试读取仓库根目录的
private-keys.json,用于在默认密钥之上做覆盖。
iOS 侧的实现是构建阶段脚本 apps/expo-go/ios/Build-Phases/generate-dynamic-macros.sh,其load_keys与get_key逻辑与 Android 侧完全对称:secrets/keys.json→template-files/keys.json→private-keys.json覆盖。两个平台共享同一套"解密密钥优先、公开占位兜底、私有覆盖"的键值解析模型。
4.2 公开占位密钥长什么样
template-files/keys.json 是随仓库公开的"默认密钥表",所有真实值被清空或替换为明显的测试占位值,例如:
{ "GOOGLE_TRACKING_ID": "", "GOOGLE_MOBILE_SDK_APP_ID": "1:300000000000:android:0000000000000000", "GOOGLE_PROJECT_NUMBER": "", "GOOGLE_CLIENT_API_KEY": "", "GOOGLE_MAPS_API_KEY": "", "FIREBASE_CLIENT_ID": "test-do-not-use.apps.googleusercontent.com", "FIREBASE_API_KEY": "A00000000000000000000000000000000000000", "FIREBASE_GCM_SENDER_ID": "999999999999", "FIREBASE_BUNDLE_ID": "host.exp.Exponent", "FIREBASE_GOOGLE_APP_ID": "1:999999999999:ios:0000000000000000", "FIREBASE_DATABASE_URL": "https://test-do-not-use.firebaseio.com" }从中可以看到密钥覆盖的典型服务面:Google Analytics / 移动 SDK(GOOGLE_*)、Google Maps(GOOGLE_MAPS_API_KEY)与 Firebase(FIREBASE_*全套)。解密后的secrets/keys.json拥有完全相同的键结构,只是值为真实配置——这正是模板替换机制能无缝切换两种来源的原因。
4.3 模板替换:从占位符到目标文件的注入
secrets/与template-files/的配合方式是:模板文件里写${KEY_NAME}占位符,构建时用密钥表中的值做正则替换,写入目标工程文件。
以 Android 为例,template-files/android/google-services.json 中的占位符包括${GOOGLE_PROJECT_ID}、${GOOGLE_MOBILE_SDK_APP_ID}、${GOOGLE_CLIENT_API_KEY}、${CERTIFICATE_HASH}、${GOOGLE_TRACKING_ID}等;template-files/android/mobile/AndroidManifest.xml 中则用${GOOGLE_MAPS_API_KEY}注入 Maps Key。
iOS 侧同理,template-files/ios/GoogleService-Info.plist 中声明了${FIREBASE_API_KEY}、${FIREBASE_CLIENT_ID}、${FIREBASE_GCM_SENDER_ID}、${FIREBASE_BUNDLE_ID}、${FIREBASE_GOOGLE_APP_ID}等占位符;apps/expo-go/ios/Build-Phases/generate-dynamic-macros.sh 中维护了FIREBASE_*十个键的替换列表,逐个用sed完成替换。
替换的目标位置由路径映射文件声明:
- template-files/android-paths.json:
google-services.json→apps/expo-go/android/app/google-services.json,以及AndroidManifest.xml→apps/expo-go/android/app/src/main/AndroidManifest.xml(含mobile/、quest/变体); - template-files/ios-paths.json:
GoogleService-Info.plist→apps/expo-go/ios/Exponent/Supporting/GoogleService-Info.plist。
替换逻辑(tools/src/dynamic-macros/generateDynamicMacros.ts)会对template-files/下的模板做逐键\$\{KEY\}全局替换,并且只有内容发生变化时才写回目标文件;debug/mobileDebug 配置还会额外注入测试权限。这意味着:没有解密密钥时,构建产物中只会出现公开占位值,官方客户端需要真实服务的场景才会受影响,本地开发与社区构建不受影响。
4.4 动态宏生成命令
Android 侧可以通过 expotools 命令手动触发整条链路(tools/src/commands/AndroidGenerateDynamicMacros.ts):
et android-generate-dynamic-macros支持--buildConstantsPath(生成ExponentBuildConstants.java的路径)与--configuration(构建配置,默认取process.env.CONFIGURATION或release)两个可选参数;iOS 侧则建议直接使用构建阶段脚本apps/expo-go/ios/Build-Phases/generate-dynamic-macros.sh(expotools 的 iOS 生成路径已被官方标记为 deprecated)。
五、对普通开发者的结论
- 你不需要这些密钥:Expo 的 SDK、模板与构建工具在无密钥状态下正常工作,回退逻辑(tools/src/dynamic-macros/generateDynamicMacros.ts 与 apps/expo-go/ios/Build-Phases/generate-dynamic-macros.sh)保证构建链路可用。
- 密钥与官方发布绑定:
secrets/里的数据只服务于官方 Expo Go 客户端及其自有服务连接,是 Expo 团队内部工作流(Google Cloud Secret Manager 存取 +./bin/unlock/./bin/lock本地同步)的一部分,与公开仓库的日常使用解耦。 - 安全模型可借鉴:即使是对外公开、且已经刻意降低敏感度的密钥集合,Expo 团队依然采用"权威存储于云 Secret Manager、本地按需解密、gitignore 防提交、模板占位符注入"的多层纵深防御设计,并明确禁止 keystore 等高危凭据入库——这套"密钥不进仓库、构建期注入、可回退可撤销"的模式,同样值得其他开源项目参考。
如果想进一步验证密钥消费链路,可以依次查看 template-files/keys.json(占位值)、tools/src/dynamic-macros/generateDynamicMacros.ts(Android 读取逻辑)、apps/expo-go/ios/Build-Phases/generate-dynamic-macros.sh(iOS 读取与替换逻辑),以及 template-files/ios/GoogleService-Info.plist 与 template-files/android/google-services.json(占位符模板实例)。
【免费下载链接】expoAn open-source framework for making universal native apps with React. Expo runs on Android, iOS, and the web.项目地址: https://gitcode.com/GitHub_Trending/ex/expo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考