如果您的 Flutter 应用程序与需要API 密钥的第三方 API 对话,您应该将其存储在哪里?
- API密钥应该存储在您自己的安全服务器上(而不是在客户端)。
- 它不应该被传回给客户端(以防止中间人攻击)
- 客户端应该只与你的服务器通信,它作为你打算使用的第三方API的一个代理。
这是因为在客户端存储API密钥是不安全的,如果密钥被泄露,会引起各种问题。
但并不是所有的钥匙都是一样的:有些钥匙可以在客户端访问,而有些则必须是秘密的,并安全地存储在服务器上(Stripe文档对此做了很好的解释)。
事实上,StackOverflow上的这个答案提供了一个很好的总结。
最后,你必须做出经济上的权衡:钥匙有多重要,你能负担多少时间或软件,对钥匙感兴趣的黑客有多复杂,他们愿意花多少时间,在钥匙被黑客攻击之前,延迟的价值有多大,任何成功的黑客会以何种规模分发钥匙,等等。像钥匙这样的小块信息比整个应用程序更难保护。从本质上讲,在客户端没有什么是不可破解的,但你肯定可以提高标准。
毫无疑问,在移动应用安全方面有很多东西需要学习(可以写整本书)。
因此,让我提供一些关于我们将在这里讨论的内容的背景。👇
一些背景
如果你像我一样,有许多 可能永远无法投入生产的 开源演示应用程序😅,你可能会倾向于在客户端存储不太敏感的API密钥(至少在开发周期的早期)。
而当涉及到API密钥和安全时,你应该避免两个主要的错误。
- 将秘密密钥提交到版本控制中,使其对互联网上的每个人都可见 🤯
- 忘记混淆你的API密钥,使攻击者更容易对你的应用程序进行反向工程并提取密钥 🛠
作为本指南的一部分,我们将学习如何避免这些错误。
本指南所涵盖的内容
我们将看看在客户端(您的 Flutter 应用程序)上存储 API 密钥的三种不同技术,以及它们的取舍。
- 在
.dart文件中硬编码密钥 - 将密钥作为命令行参数传递
--dart-define - 用ENVied包从
.env文件加载密钥
在此过程中,我们将牢记这些原则。
- 不要把你的API密钥添加到版本控制中
- 如果你在客户端存储API密钥,请确保对其进行混淆处理
在本指南的最后,你将对如何安全地存储API密钥有更好的理解。
而且我还将包括一个安全检查表,你可以在你的Flutter项目中遵循。
⚠️这些技术并不是万无一失的。如果我们有一个不能丢失的API密钥,我们应该把它存储在服务器上。安全的客户端-服务器通信涉及许多考虑因素,超出了本文的范围(更多细节见底部的链接)。
准备好了吗?让我们开始吧!👇
1.在Dart文件中硬编码密钥
存储我们的API密钥的一个简单而有效的方法是把它保存在一个像这样的Dart文件里。
// api_key.dart
final tmdbApiKey = 'a1b2c33d4e5f6g7h8i9jakblc';
为了确保钥匙不被添加到git中,我们可以在同一文件夹中添加一个.gitignore 文件,其中包括这些内容。
# Hide key from version control
api_key.dart
如果我们做得正确,api_key.dart 在资源管理器中应该是这样显示的。
将api_key.dart添加到.gitignore后的文件资源管理器
而如果我们需要在任何地方使用该钥匙,我们可以导入api_key.dart ,并读取它。这里有一个例子,使用dio包从TMDB API中获取数据。
import 'api_key.dart'; // import it here
import 'package:dio/dio.dart';
Future<TMDBMoviesResponse> fetchMovies() async {
final url = Uri(
scheme: 'https',
host: 'api.themoviedb.org',
path: '3/movie/now_playing',
queryParameters: {
'api_key': tmdbApiKey, // read it here
'include_adult': 'false',
'page': '$page',
},
).toString();
final response = await Dio().get(url);
return TMDBMoviesResponse.fromJson(response.data);
}
这种方法非常简单,但它有一些缺点。
- 很难为不同的口味/环境管理不同的API密钥
- 密钥以明文形式存储在
api_key.dart文件中,使攻击者的工作更容易。
我们不应该在我们的源代码中硬编码API密钥。如果我们错误地将它们添加到版本控制中,它们将保留在git历史中,即使我们后来gitignore它们。
所以我们来看看第二个选项。👇
2.使用 --dart-define 传递密钥
另一种方法是在编译时用--dart-define 标志传递API密钥。
这意味着我们可以像这样运行应用程序。
flutter run --dart-define TMDB_KEY=a1b2c33d4e5f6g7h8i9jakblc
然后,在我们的Dart代码里面,我们可以这样做。
const tmdbApiKey = String.fromEnvironment('TMDB_KEY');
if (tmdbApiKey.isEmpty) {
throw AssertionError('TMDB_KEY is not set');
}
// TODO: use api key
String.fromEnvironment方法允许我们指定一个可选的defaultValue,它在密钥没有设置的情况下充当备用。但正如我们所说,我们不应该在代码中硬编码API密钥(无论它是否被gitignored),所以在这里使用defaultValue不是一个好主意。
使用-dart-define编译和运行应用程序
使用--dart-define 的主要好处是,我们不再在源代码中硬编码敏感键。
但是当我们编译我们的应用程序时,密钥仍然会在发布的二进制文件中得到体现。
API密钥和源代码结合起来产生发布二进制文件
为了降低风险,我们可以在进行发布构建时混淆我们的Dart代码(下面会有更多介绍)。
另外,如果我们有很多钥匙,运行应用程序就会变得不切实际。
flutter run \
--dart-define TMDB_KEY=a1b2c33d4e5f6g7h8i9jakblc \
--dart-define STRIPE_PUBLISHABLE_KEY=pk_test_aposdjpa309u2n230ibt23908g \
--dart-define SENTRY_KEY=https://aoifhboisd934y2fhfe@a093qhq4.ingest.sentry.io/2130923
为了处理这个问题,我们可以使用启动配置。👇
在VSCode的 launch.json里面存储密钥
如果我们使用VSCode,我们可以编辑.vscode/launch.json 文件,并在我们的启动配置中添加一些args 。
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch",
"request": "launch",
"type": "dart",
"program": "lib/main.dart",
"args": [
"--dart-define",
"TMDB_KEY=a1b2c33d4e5f6g7h8i9jakblc",
"--dart-define",
"STRIPE_PUBLISHABLE_KEY=pk_test_aposdjpa309u2n230ibt23908g",
"--dart-define",
"SENTRY_KEY=https://aoifhboisd934y2fhfe@a093qhq4.ingest.sentry.io/2130923"
]
}
]
}
此外,如果需要的话,我们可以用不同的API密钥定义多个启动配置(每种口味一个)。
如果你使用IntelliJ或Android Studio,你可以使用运行/调试配置来实现同样的结果。
但事实证明,这导致了一个鸡和蛋的问题。🐣
- 如果我们在
launch.json里面硬编码API密钥,我们必须把它添加到.gitignore(因为密钥不应该被添加到版本控制中)。 - 如果
launch.json被gitignored,我们对项目进行新的签出,我们将无法运行它,直到我们再次创建launch.json,并设置API密钥。
我的下一篇文章将讨论如何与其他开发者分享API密钥。
但现在,让我们尝试一种不依赖IDE特定设置的不同方法。👇
3.从.env文件加载密钥
.env 是一种流行的文件格式,它的出现是为了给开发者提供一个安全的地方来存储敏感的应用秘密,如API密钥。
为了在Flutter中使用这个,我们可以在项目的根部添加一个.env 文件。
# example .env file
TMDB_KEY=a1b2c33d4e5f6g7h8i9jakblc
# add more keys here if needed
而由于这个文件包含了我们的API密钥,我们应该把它添加到.gitignore 。
# exclude all .env files from source control
*.env
然后,我们可以去pub.dev找到一个包,我们可以用它来读取.env 文件并提取我们的API密钥。
进入ENVied
ENVied包帮助我们生成一个Dart类,其中包含我们的.env 文件中的值。
例如,给定这个.env 文件,其中包含我们的API密钥。
# example .env file
TMDB_KEY=a1b2c33d4e5f6g7h8i9jakblc
我们可以创建一个env.dart 文件,看起来像这样。
import 'package:envied/envied.dart';
part 'env.g.dart';
@Envied(path: '.env')
abstract class Env {
@EnviedField(varName: 'TMDB_KEY')
static const tmdbApiKey = _Env.tmdbApiKey;
}
然后,我们可以运行这个命令。
flutter pub run build_runner build --delete-conflicting-outputs
这将使用build_runner来生成一个看起来像这样的env.g.dart 文件。
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'env.dart';
// **************************************************************************
// EnviedGenerator
// **************************************************************************
class _Env {
static const tmdbApiKey = 'a1b2c33d4e5f6g7h8i9jakblc';
}
因此,我们可以导入env.dart ,并根据需要访问tmdbApiKey 。
由于API密钥被硬编码在env.g.dart ,我们应该把它添加到.gitignore 。
# exclude the API key from version control
env.g.dart
因此,我们应该在项目资源管理器中看到以下文件。
添加api_key.dart到.gitignore后的文件资源管理器
混淆的情况如何?
到目前为止,我们已经设法从我们的.env 文件中生成了一个tmdbApiKey 常量。
但这仍然是以明文存储的,如果攻击者试图对我们的应用程序进行反向工程,他们可能会提取密钥。
为了使我们的API密钥更加安全,我们可以使用混淆技术。
这可以通过在@EnviedField 注释中添加obfuscate: true 标志来实现。
import 'package:envied/envied.dart';
part 'env.g.dart';
@Envied(path: '.env')
abstract class Env {
@EnviedField(varName: 'TMDB_KEY', obfuscate: true)
static final tmdbApiKey = _Env.tmdbApiKey;
}
如果我们使用
obfuscate标志,我们应该将该变量声明为final,而不是const。
然后,我们可以重新运行代码生成步骤。
flutter pub run build_runner build --delete-conflicting-outputs
如果我们检查生成的env.g.dart 文件,我们会看到API密钥已经被混淆了。
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'env.dart';
// **************************************************************************
// EnviedGenerator
// **************************************************************************
class _Env {
static const List<int> _enviedkeytmdbApiKey = [
3083777460,
1730462941,
// many other lines
];
static const List<int> _envieddatatmdbApiKey = [
3083777414,
1730462956,
// many other lines
];
static final tmdbApiKey = String.fromCharCodes(
List.generate(_envieddatatmdbApiKey.length, (i) => i, growable: false)
.map((i) => _envieddatatmdbApiKey[i] ^ _enviedkeytmdbApiKey[i])
.toList(growable: false),
);
}
很好!API密钥不再是硬编码,如果攻击者反编译我们的应用程序,它就更难被提取。
参见ENVied包的用法部分,了解如何在多种环境/风味中使用它。
API密钥。代码生成与运行时读取
上面描述的带有混淆功能的代码生成方法更安全(虽然不是100%的防故障)。
相比之下,诸如flutter_dotenv等软件包的工作方式是将.env 文件添加到assets文件夹中,并在运行时读取其内容。这是非常不安全的,因为任何资产文件都可以通过解压缩发布的APK轻松提取,从而暴露出环境变量。
**所以不要错误地使用flutter_dotenv**作为你的API密钥。相反,使用ENVied包并启用混淆功能。
API密钥安全检查表
如果您选择使用.env 文件和ENVied包,请按照以下步骤来保护您的 API 密钥。
- 创建一个
.env文件,以明文方式存储你的API密钥 - 将该
.env.文件添加到.gitignore - 安装ENVied软件包
- 创建一个
env.dart文件并定义Env类,每个API密钥有一个字段,使用obfuscate: true - 运行代码生成步骤
- 将
env.g.dart文件添加到.gitignore - 导入
env.dart,并根据需要读取API密钥
关于使用这种方法的例子,请查看我在GitHub上的电影应用。
总结
我们现在已经探索了三种在客户端存储API密钥的技术。
- 在
.dart文件中硬编码密钥**(不建议**)。 - 将密钥作为命令行参数传递,用
--dart-define - 用ENVied包从
.env文件中加载密钥。
那么,我们应该选择哪一个?
选项1应该被避免,因为硬编码的API密钥是危险的,如果我们不小心的话,可能会出现在git历史中。
选项2是定义自定义环境变量的官方方式,但如果我们有很多--dart-define,它可能变得有点不切实际。
选项3是我最喜欢的,因为所有的敏感键都可以存储在一个.env 文件中,并在生成的代码中进行模糊处理。
作为一个额外的步骤,当你构建你的应用程序的发布版本时,对你的整个代码进行混淆是一个好主意,官方文档解释了如何做到这一点。
而当我们在客户端存储API密钥时,可以使用混淆技术来降低风险,但把它们放在服务器上更安全。
因此,请确保你阅读你所选择的API供应商的文档,并遵循推荐的准则。
关于移动应用安全的更多细节,请查看这些资源。
- 为什么OAuth API密钥和秘密在移动应用中不安全?
- 在应用程序中存储和保护私人API密钥的最佳做法
- 如何保证API密钥的安全?| r/FlutterDev
- 开发人员如何在2022年保护Flutter™移动应用的安全
- 基于编译器的移动应用安全与应用屏蔽和无代码移动应用安全的对比
- 当你无法保护移动应用密匙时该怎么办?
结束语
本指南以一个开放的问题结束。
如果我们不在版本控制中存储我们的API密钥,那么当我们在不同的机器上检查我们的项目时,我们如何与其他团队成员分享它们或检索它们?
我将在下一篇文章中介绍这个问题,我们将看到如何将1Password CLI整合到我们的开发工作流程中。
我们还将学习如何在CI上将秘密存储为环境变量,并在用GitHub Actions部署应用程序时使用它们。