Flutter 基础体系 · 第 19/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 应用安全:Secret、网络、存储、WebView、证书和供应链

1. 先建立安全边界:Flutter 代码不是可信执行环境

移动应用安全的第一个边界是:发布到用户设备上的应用,最终都处于用户控制之下。用户可以调试进程、修改文件、代理网络、提取 APK/IPA、反编译 Dart AOT 产物,甚至运行修改过的系统。

因此,客户端可以执行:

  • 展示界面;
  • 保存用户自己的缓存;
  • 发起经过服务器授权的请求;
  • 持有短期、可撤销的会话凭据;
  • 进行输入校验和体验优化。

客户端不能被当作:

  • 私钥保管箱;
  • 服务端授权逻辑;
  • 永久可信的计费结果来源;
  • 只有客户端知道的 Secret 存储位置;
  • 能阻止设备所有者调试的安全边界。

可以把一次请求的安全条件写成:

允许操作=TLS 保证传输对端服务端验证身份服务端验证权限服务端验证业务状态\text{允许操作} = \text{TLS 保证传输对端} \land \text{服务端验证身份} \land \text{服务端验证权限} \land \text{服务端验证业务状态}

客户端的 if (isAdmin)、隐藏按钮或本地保存的 role=admin 都不能替代服务端的后面三项。

典型数据流如下:

flowchart LR
    UI[Flutter UI] --> C[客户端状态与校验]
    C --> N[HTTPS 请求]
    N --> T[平台 TLS 栈]
    T --> S[服务端认证与授权]
    S --> DB[(服务端数据库)]

    C --> P[Preferences/文件/SQLite]
    C --> K[平台 Keychain/Keystore]
    C --> W[WebView]

这里有三个不同的信任区域:

  1. 应用进程:能读取 Dart 运行时可访问的数据。
  2. 操作系统提供的安全存储:通常比普通文件更难被其他应用读取,但在越狱、Root、调试或备份场景下仍不能假设绝对安全。
  3. 服务端:真正决定资源是否允许访问。

后文的 Secret、网络、存储、WebView、证书和供应链,分别解决不同层次的问题,不能互相替代。


2. Secret:客户端中的“秘密”通常不是秘密

2.1 Secret 的定义和分类

Secret 是一旦泄露就能让攻击者获得未授权能力,且不能仅凭公开信息重新推导的数据。例如:

  • 服务端 API 私钥;
  • 云厂商管理密钥;
  • 数据库密码;
  • JWT 签名私钥;
  • 第三方支付或地图服务的服务端密钥;
  • 用来解密所有用户数据的固定主密钥。

与之不同的是,某些值虽然称为“key”,但本质是公开配置:

  • OAuth 的客户端 ID;
  • Android 包名与 iOS Bundle ID;
  • 某些只允许按包名、域名或签名限制的公开 SDK 标识;
  • API 的公开地址。

公开配置仍需要限制来源和权限,但不能把它误认为 Secret。

2.2 为什么硬编码、混淆和环境变量都不能保护客户端 Secret

下面的代码不会保护密钥:

const apiKey = 'sk_live_very_sensitive_value';

把它改成:

const apiKey = String.fromEnvironment('API_KEY');

也只改变了密钥进入构建产物的时间。如果它参与了发布构建,攻击者仍可以通过以下路径得到它:

  1. 从 APK、IPA 或桌面安装包提取资源和字符串;
  2. 反编译或反汇编调用点;
  3. 运行时调试,在请求发送前读取内存;
  4. 观察应用发出的请求头或请求参数;
  5. 从构建日志、CI 缓存或错误报告中获取。

Dart 的 --dart-define 适合注入环境配置,例如:

flutter build apk \
  --release \
  --dart-define=API_BASE_URL=https://api.example.com

对应代码:

const apiBaseUrl = String.fromEnvironment(
  'API_BASE_URL',
  defaultValue: 'https://api.example.com',
);

这里 API_BASE_URL 不是安全存储。它只是构建时常量;如果值需要保密,就不能放进客户端。

代码混淆也只能提高逆向成本。混淆的目标是让符号名更难读,不是让运行时无法获得数据。更不能用 Base64、字符串拆分、异或或自定义编码来代替加密:

// 这不是加密;原文可以直接恢复。
final value = base64Decode('c2VjcmV0').toString();

2.3 正确的数据流:把真正的 Secret 留在服务端

假设应用需要调用一个第三方支付服务。错误架构是:

Flutter 应用 -> 第三方支付服务
                 需要服务端私钥

正确架构是:

Flutter 应用 --用户凭据--> 自有服务端
自有服务端 --服务端私钥--> 第三方支付服务

具体过程:

  1. Flutter 使用用户登录态请求自有服务端;
  2. 服务端验证访问令牌、用户身份、订单状态和金额;
  3. 服务端使用只存在于服务端的第三方私钥;
  4. 服务端把最小必要结果返回给 Flutter;
  5. Flutter 不接触第三方私钥。

如果第三方只能提供客户端 SDK,则应使用其明确设计为公开的客户端凭据,并在服务端和第三方控制台限制:

  • 包名、Bundle ID;
  • 签名证书;
  • iOS Associated Domains 或 Android 应用限制;
  • API 来源域名;
  • 可调用的 API 范围;
  • 配额和告警。

2.4 访问令牌也属于 Secret

“不是永久密钥,所以可以写入普通文件”是常见误解。访问令牌在有效期内同样具有权限,属于需要保护的凭据。

合理的令牌生命周期通常是:

stateDiagram-v2
    [*] --> 未登录
    未登录 --> 已登录: 登录成功
    已登录 --> 令牌有效
    令牌有效 --> 令牌有效: 普通请求
    令牌有效 --> 刷新中: 收到 401 且未重试
    刷新中 --> 令牌有效: 刷新成功
    刷新中 --> 未登录: 刷新失败/撤销
    令牌有效 --> 未登录: 主动退出或服务端撤销

刷新逻辑必须防止并发请求同时刷新:

class TokenManager {
  TokenManager(this._refreshToken);

  final Future<String?> Function() _refreshToken;
  Future<String?>? _refreshing;

  Future<String?> refreshOnce() {
    final current = _refreshing;
    if (current != null) return current;

    final future = _refreshToken();
    _refreshing = future;

    return future.whenComplete(() {
      if (identical(_refreshing, future)) {
        _refreshing = null;
      }
    });
  }
}

这个模式的因果关系是:

  1. 第一个收到 401 的请求创建刷新 Future;
  2. 后续并发请求复用同一个 Future;
  3. 刷新成功后所有等待者得到同一个新令牌;
  4. 刷新失败时统一进入重新登录或清除凭据流程;
  5. identical 检查避免旧 Future 完成时错误清除更新后的刷新任务。

实际项目中还要处理:

  • 令牌刷新接口本身不能无限重试;
  • 旧刷新令牌被轮换后,服务端可能只接受最新令牌;
  • 退出登录必须取消或使待发送请求失效;
  • 日志不能打印 Authorization、Cookie、刷新令牌和完整请求体。

3. 网络安全:HTTPS 保护的是传输链路,不是业务授权

3.1 HTTPS、TLS 和证书链

HTTPS 是 HTTP 运行在 TLS 之上。TLS 建立连接时大致经历:

  1. 客户端发送支持的协议版本、密码套件和随机数;
  2. 服务端返回证书链、协商结果和密钥交换参数;
  3. 客户端验证服务端证书;
  4. 双方通过密钥交换得到会话密钥;
  5. 后续 HTTP 数据使用会话密钥加密和完整性保护。

证书通常包含:

  • 主体名称;
  • 有效期;
  • 公钥;
  • Subject Alternative Name(SAN)中的域名;
  • 签发者;
  • CA 签名;
  • 用途约束。

客户端不是简单地判断“证书文件存在”,而是验证:

可信连接=域名匹配当前时间有效证书链可追溯到信任锚用途允许服务器认证\text{可信连接} = \text{域名匹配} \land \text{当前时间有效} \land \text{证书链可追溯到信任锚} \land \text{用途允许服务器认证}

这套验证通常由 Android、iOS、桌面平台的 TLS 实现完成。Flutter dart:ioHttpClient 会使用平台或运行时的信任配置,但具体根证书、代理行为和系统策略由平台决定。

3.2 Flutter 中的基本 HTTPS 请求

import 'dart:convert';
import 'dart:io';

Future<Map<String, dynamic>> fetchProfile({
  required String accessToken,
}) async {
  final client = HttpClient();

  try {
    final request = await client.getUrl(
      Uri.parse('https://api.example.com/v1/profile'),
    );

    request.headers
      ..set(HttpHeaders.acceptHeader, 'application/json')
      ..set(HttpHeaders.authorizationHeader, 'Bearer $accessToken');

    final response = await request.close();
    final body = await response.transform(utf8.decoder).join();

    if (response.statusCode < 200 || response.statusCode >= 300) {
      throw HttpException(
        'profile request failed: ${response.statusCode}',
        uri: request.uri,
      );
    }

    final decoded = jsonDecode(body);
    if (decoded is! Map<String, dynamic>) {
      throw const FormatException('unexpected response shape');
    }
    return decoded;
  } on SocketException {
    rethrow; // 网络不可达、DNS 失败等
  } on HandshakeException {
    rethrow; // TLS 握手或证书验证失败
  } finally {
    client.close(force: true);
  }
}

需要注意:

  • HttpClient 必须关闭,否则连接资源可能泄漏;
  • HTTP 状态码不是异常,必须显式检查;
  • jsonDecode 成功不代表数据结构正确;
  • 不应把整个响应体写入生产日志;
  • HandshakeException 和业务 401 是不同故障,恢复策略也不同。

生产应用通常会使用 HTTP 客户端库统一实现超时、取消、重试、拦截器和错误映射,但这些库不会自动解决服务端授权问题。

3.3 明文 HTTP 的平台差异

生产环境应优先使用 HTTPS。明文 HTTP 存在窃听、篡改和降级风险。

平台差异包括:

  • Android:应用网络安全策略可以禁止明文流量;目标 SDK、系统版本和 networkSecurityConfig 会影响行为。
  • iOS:App Transport Security(ATS)通常要求安全连接;对特定域名的例外需要在 Info.plist 中明确配置。
  • 桌面:Flutter 桌面应用依赖操作系统的网络和证书环境,企业代理、自定义根证书和系统更新会影响结果。
  • Web:浏览器执行混合内容、CORS、证书和安全上下文策略;Flutter Web 不能绕过浏览器的 TLS 或 CORS 规则。

开发环境若必须访问本机服务,应区分环境,而不是把全局验证关闭。例如使用本地测试域名、开发证书和仅开发构建启用的网络策略。把生产应用配置为允许任意 HTTP 是发布风险,不是调试技巧。

3.4 不要接受任意证书

以下代码会让 TLS 认证失去意义:

final client = HttpClient()
  ..badCertificateCallback =
      (X509Certificate cert, String host, int port) => true;

badCertificateCallback 的语义是:平台默认证书验证失败时,是否接受该证书。返回 true 等于告诉客户端“即使证书不可信也继续”,攻击者可以用自签名证书进行中间人攻击。

如果必须在开发环境接受本地自签名证书,应满足:

  • 只在 debug 或专用开发 flavor 编译;
  • 只匹配明确的主机和端口;
  • 不允许生产构建包含该逻辑;
  • 在 CI 中验证 release 构建不会启用;
  • 不把用户输入的主机名直接作为放行条件。

3.5 证书固定(Certificate Pinning)的边界

证书固定是除了系统 CA 验证外,再要求服务端证书或公钥指纹等于应用内预置值。它可以减少“设备信任库被额外安装 CA”时的风险,但会引入证书轮换和故障恢复问题。

固定证书指纹的判断可以表示为:

接受连接=系统 TLS 验证成功H(证书或公钥)P\text{接受连接} = \text{系统 TLS 验证成功} \land H(\text{证书或公钥}) \in P

其中:

  • HH 是哈希函数,常见为 SHA-256;
  • PP 是应用内预置的允许指纹集合。

必须预置至少一个备用指纹,轮换时先让服务端同时使用新旧证书,再发布包含新指纹的客户端,最后移除旧证书。否则证书一更换,旧版本应用就会全部无法联网。

还要区分:

  • 证书固定:固定某张叶证书,轮换频繁,维护成本高;
  • 公钥固定:固定公钥或 SPKI,证书续期但密钥不变时仍可工作;
  • CA 固定:范围更宽,安全收益和误配置风险取决于实现。

Dart HttpClient 的默认回调并不是通用的证书固定 API;在 Android 和 iOS 上,可靠的固定通常需要平台网络栈、对应 HTTP 库或经过审查的插件支持。不能把 badCertificateCallback 当作 pinning。移动应用还要考虑:

  • 企业代理和调试代理;
  • 证书轮换;
  • 紧急撤销;
  • 无网络时的错误提示;
  • 备用域名和灾备服务是否也在固定范围内。

除非威胁模型明确要求,很多应用更适合依靠正确的系统 TLS、服务端证书管理、短期令牌和服务端风控,而不是贸然加入固定逻辑。


4. 存储安全:机密性、完整性和可恢复性是三个问题

本地存储不只有“加不加密”一个维度:

  • 机密性:攻击者能否读到内容;
  • 完整性:攻击者能否修改内容而不被发现;
  • 可用性:应用能否在升级、迁移、卸载、备份恢复后继续工作;
  • 生命周期:退出登录、令牌过期和用户删除数据时是否清理。

4.1 Preferences 适合配置,不适合 Secret

Preferences 类存储适合:

  • 首次启动标记;
  • 主题模式;
  • 非敏感的用户偏好;
  • 最后选择的筛选条件。

它通常以键值形式保存,读写简单,但不提供适合保存高价值凭据的安全保证。平台实现可能使用 XML、NSUserDefaults 或其他普通持久化机制,不能把 API 名称中的“存储”理解成“加密保险箱”。

import 'package:shared_preferences/shared_preferences.dart';

Future<void> saveThemeMode(bool isDark) async {
  final preferences = await SharedPreferences.getInstance();
  await preferences.setBool('theme.dark', isDark);
}

Future<bool> loadThemeMode() async {
  final preferences = await SharedPreferences.getInstance();
  return preferences.getBool('theme.dark') ?? false;
}

这里保存的是偏好,不是授权凭据。即使攻击者修改 theme.dark,最多改变显示效果;如果同样方式保存 isPremium=true 并让服务端相信它,就构成业务漏洞。

4.2 文件存储:路径和权限不等于加密

文件适合图片、导出文件、离线缓存和日志,但应明确:

  • 应用沙箱限制的是其他普通应用访问,不是设备所有者;
  • Root、越狱、调试、备份或恶意插件可能改变威胁模型;
  • 文件删除不一定立即从闪存介质物理擦除;
  • 临时文件、崩溃转储和日志可能复制敏感数据;
  • 文件名、目录名和 SQLite WAL 文件也可能泄露信息。

文件写入至少应采用临时文件加替换,避免进程崩溃留下半个 JSON:

import 'dart:convert';
import 'dart:io';

Future<void> writeJsonAtomically(File target, Object value) async {
  final temporary = File('${target.path}.tmp');
  final content = jsonEncode(value);

  await temporary.writeAsString(
    content,
    flush: true,
  );

  await temporary.rename(target.path);
}

这段代码降低了部分写入中断风险,但不提供加密,也不保证跨文件系统重命名的原子语义。敏感内容还需要:

  1. 生成随机加密密钥;
  2. 把密钥存入 Android Keystore 或 iOS Keychain 的平台安全存储;
  3. 使用带认证的加密模式,例如 AES-GCM 或 ChaCha20-Poly1305;
  4. 为每次加密使用唯一随机 nonce;
  5. 验证认证标签失败并拒绝解密结果;
  6. 处理密钥丢失导致的数据不可恢复。

不要固定写死 nonce,也不要把“加密密钥”和密文放在同一个普通文件中。加密只提供机密性和篡改检测,不能阻止正在运行的应用自己读取明文;应用进程被完全控制时,攻击者仍可在解密后截获数据。

4.3 SQLite:数据库结构安全不等于数据库内容加密

SQLite 适合需要查询、事务和关系结构的本地数据,例如:

  • 离线队列;
  • 大量缓存;
  • 搜索索引;
  • 多表关系;
  • 可迁移的业务草稿。

SQLite 的核心能力包括事务和查询,但标准 SQLite 本身不自动提供透明加密。常见方案有:

  • 使用 SQLCipher 等加密 SQLite 变体;
  • 在应用层加密敏感字段;
  • 仅把非敏感缓存放入 SQLite;
  • 通过插件调用平台数据库或加密库。

应用层字段加密的缺点是无法直接对密文做普通范围查询、排序或全文搜索。数据库级加密维护成本更高,但可以覆盖页、索引和临时数据;具体能力取决于使用的实现和插件,不能仅根据“SQLite”三个字判断。

4.4 迁移是安全问题,不只是版本管理

假设旧版本表结构为:

CREATE TABLE notes (
  id INTEGER PRIMARY KEY,
  content TEXT NOT NULL
);

新版本需要增加 is_archived。安全的迁移需要:

  1. 在事务中检查当前 schema version;
  2. 执行对应版本的迁移;
  3. 更新 schema version;
  4. 迁移失败时回滚;
  5. 对旧数据进行约束和默认值验证。

示例:

BEGIN;

ALTER TABLE notes
ADD COLUMN is_archived INTEGER NOT NULL DEFAULT 0;

PRAGMA user_version = 2;

COMMIT;

如果在 ALTER TABLE 成功后、更新版本号前进程崩溃,下一次启动可能重复执行迁移。因此迁移框架必须让“当前版本判断”和“执行步骤”具备明确的恢复语义。不同 SQLite 封装对 DDL 事务、user_version 和异常回滚的支持细节可能不同,应以实际插件文档和测试结果为准。

必须测试这些路径:

  • 从多个旧版本直接升级到当前版本;
  • 迁移中断后再次启动;
  • 数据库文件损坏;
  • 存储空间不足;
  • 加密密钥丢失;
  • 降级安装;
  • 备份恢复得到旧 schema;
  • 多 isolate 或多个启动流程同时打开数据库。

4.5 退出登录时的清理边界

退出登录通常至少需要清除:

  • 访问令牌;
  • 刷新令牌;
  • 用户身份缓存;
  • 需要用户隔离的离线数据库或缓存;
  • WebView Cookie、localStorage 和会话;
  • 上传队列中与用户绑定的数据。

但不能简单删除所有本地数据,因为应用配置、崩溃诊断和匿名缓存可能不属于用户身份。正确做法是给每类数据标注归属和生命周期,而不是按文件名猜测。


5. WebView:把远程网页嵌入到应用进程中

5.1 WebView 的安全模型

WebView 是平台提供的网页渲染和 JavaScript 执行组件。它同时涉及:

  • URL 导航;
  • JavaScript;
  • Cookie 和 Web Storage;
  • 文件选择;
  • 相机、麦克风、定位等权限;
  • JavaScript 与 Dart/Native 的桥接;
  • 页面内的 TLS 和浏览器安全策略。

WebView 不是普通文本控件。加载不可信 URL,相当于把一部分网页应用和脚本放进你的应用上下文。

一个基本的 webview_flutter 示例:

import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

class TrustedPage extends StatefulWidget {
  const TrustedPage({super.key});

  @override
  State<TrustedPage> createState() => _TrustedPageState();
}

class _TrustedPageState extends State<TrustedPage> {
  late final WebViewController controller;

  static final trustedHost = Uri.parse('https://app.example.com');

  @override
  void initState() {
    super.initState();

    controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.disabled)
      ..setNavigationDelegate(
        NavigationDelegate(
          onNavigationRequest: (request) {
            final uri = Uri.tryParse(request.url);
            final allowed = uri != null &&
                uri.scheme == trustedHost.scheme &&
                uri.host == trustedHost.host;

            return allowed
                ? NavigationDecision.navigate
                : NavigationDecision.prevent;
          },
        ),
      )
      ..loadRequest(trustedHost);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('帮助中心')),
      body: WebViewWidget(controller: controller),
    );
  }
}

这段代码的前提是项目已经添加与当前版本兼容的 webview_flutter 依赖,并按 Android/iOS 平台要求完成配置。该插件不是 Flutter SDK 内置 API,具体构造器和平台实现应以项目锁定的插件版本为准。

关键点是:

  1. 默认关闭 JavaScript,只有业务确实需要时才打开;
  2. 导航时解析完整 URI,而不是简单使用 startsWith
  3. 同时检查 scheme 和 host;
  4. 不允许 javascript:, file:, data: 等不需要的 scheme;
  5. 不把任意页面跳转到“可信页面”;
  6. 外部链接应交给系统浏览器,并明确用户意图。

5.2 导航校验的常见错误

下面的判断不安全:

request.url.startsWith('https://app.example.com')

它可能错误放行:

https://app.example.com.attacker.example/
https://app.example.com@attacker.example/

更稳妥的判断应使用 URI 解析,并根据业务决定是否允许:

  • 精确 host;
  • 特定子域名;
  • 精确端口;
  • 特定 path;
  • 是否允许重定向;
  • 是否允许自定义 scheme。

只检查最终 URL 也不够,因为攻击者可能先利用页面中的链接、表单或重定向导航到外部站点。导航策略应在每一次请求和重定向节点执行。

5.3 JavaScript Bridge 是高风险边界

如果页面可以调用 Dart 方法,桥接接口必须当作远程输入处理。危险设计包括:

网页 JavaScript -> invoke("getAccessToken") -> Flutter 返回令牌
网页 JavaScript -> invoke("openFile", arbitraryPath)
网页 JavaScript -> invoke("pay", arbitraryAmount)

桥接的安全条件应是:

允许桥接操作=来源可信消息格式正确参数满足业务约束操作需要的权限存在\text{允许桥接操作} = \text{来源可信} \land \text{消息格式正确} \land \text{参数满足业务约束} \land \text{操作需要的权限存在}

即使页面来自自己的域名,也要考虑 XSS、被入侵的 CDN、第三方脚本和页面重定向。桥接接口应该:

  • 只暴露最小功能;
  • 使用明确的消息 schema;
  • 严格限制参数;
  • 不返回令牌、Cookie、文件路径等高价值数据;
  • 不允许通过消息构造任意原生调用;
  • 对来源和当前导航状态进行校验;
  • 在退出登录时清理 WebView 会话。

5.4 WebView Cookie 与 Flutter 请求不是天然共享

WebView 使用平台 WebView 的 Cookie、缓存和存储;dart:io 或其他 HTTP 客户端使用自己的 Cookie 管理。二者通常不会自动同步。

因此,以下假设不可靠:

用户在 WebView 中登录后,Flutter 的 HTTP 请求自然就已登录。

如果业务需要统一登录,应明确选择:

  • 使用系统浏览器和 OAuth/OIDC 回调;
  • 使用平台认证会话;
  • 通过一次性授权码交换 Flutter 自己的令牌;
  • 明确、安全地同步有限状态,而不是复制长期 Cookie。

把长期访问令牌拼接进 WebView URL 也不安全,因为 URL 可能进入历史记录、代理日志、截图、Referer 或崩溃信息。

5.5 平台差异

  • Android/iOS:通常有系统 WebView,JavaScript、混合内容、文件访问和权限行为受平台版本影响。
  • 桌面:是否支持 WebView 取决于插件和平台实现,不能假设移动端 API 与行为完全一致。
  • Web:Flutter Web 运行在浏览器中,没有同样意义上的原生 WebView;嵌入网页常通过 iframe,而 iframe 受 CSP、X-Frame-Options、CORS 和浏览器同源策略限制。

6. 证书:TLS 证书、应用签名证书和身份绑定不是一回事

“证书”至少有三种常被混淆的含义。

6.1 TLS 服务器证书

它证明某个域名的服务器拥有对应私钥,并由客户端信任的 CA 签发。它保护的是:

客户端 <-> 服务器

TLS 证书私钥必须只存在于服务器或安全的密钥管理系统中,绝不能打包进 Flutter 应用。

证书失效、域名不匹配、链不完整或系统时间错误时,常见表现是:

  • HandshakeException
  • Android 的 SSLHandshakeException
  • iOS 的 ATS 或 URL Loading System 错误;
  • Web 浏览器显示证书或混合内容错误。

诊断时应先区分:

  1. DNS 是否解析到正确地址;
  2. 系统时间是否正确;
  3. 服务器是否发送完整中间证书链;
  4. SAN 是否包含访问域名;
  5. Android/iOS/桌面系统是否信任签发 CA;
  6. 是否被企业代理替换证书;
  7. 是否误启用了证书固定。

6.2 Android 应用签名证书

Android APK/AAB 使用签名密钥签名。签名用于:

  • 证明更新包来自同一发布者;
  • 让 Android 判断更新包是否可以覆盖安装;
  • 支持某些 API 或服务将应用包名与签名指纹绑定。

发布签名私钥不能放入仓库,也不能放进 Flutter 资产。CI 应通过受保护的密钥库、签名服务或受控凭据注入完成签名。签名密钥丢失可能导致无法以原应用身份发布更新;泄露则可能导致攻击者签署伪造包,具体影响取决于分发渠道和平台保护机制。

6.3 iOS 证书与 provisioning profile

iOS 发布涉及开发者证书、分发证书、Provisioning Profile、Bundle ID 和 entitlements。它们共同决定:

  • 哪个团队可以签署应用;
  • 哪个 Bundle ID 可以运行;
  • 哪些能力可用;
  • 应用能否安装或上架。

APNs、Associated Domains、Keychain Access Groups 等能力也和 entitlements、签名配置有关。更换团队、Bundle ID 或 profile 时,常见故障不是 Flutter Dart 代码错误,而是原生签名和能力配置不一致。

6.4 证书轮换与应用发布顺序

TLS 证书轮换和应用签名密钥轮换的失败模式不同:

  • TLS 证书轮换错误,可能导致所有客户端无法建立连接;
  • 应用签名密钥错误,可能导致更新包无法覆盖旧版本;
  • pinning 指纹遗漏,可能导致只有旧版或新版客户端无法联网;
  • profile/entitlement 错误,可能导致安装成功但某能力运行失败。

因此应在发布前验证:

构建产物身份 -> 签名验证
应用配置 -> Bundle ID/包名验证
网络域名 -> TLS 链和有效期验证
服务端轮换 -> 旧版和新版客户端兼容验证

7. 供应链:依赖、构建工具和发布凭据都是攻击面

7.1 Flutter 供应链包含哪些组件

Flutter 应用的供应链不只有 Dart 包,还包括:

  • Flutter SDK 和 Dart SDK;
  • Android Gradle Plugin、Gradle、Android SDK;
  • Kotlin/Java 依赖;
  • CocoaPods、Xcode 和 iOS 原生依赖;
  • Dart/Flutter packages;
  • Git 依赖和本地 path 依赖;
  • 构建脚本、CI action 和 Docker 镜像;
  • 签名密钥、发布 token 和商店凭据;
  • 远程配置、CDN 资源和 WebView 页面。

任一环节被篡改,都可能在构建产物中植入恶意逻辑。

7.2 依赖解析、锁文件和版本范围

pubspec.yaml 中的约束描述允许范围,pubspec.lock 记录一次解析后的具体版本。应用项目应提交 pubspec.lock,使开发机和 CI 使用一致的解析结果。

常用检查命令:

flutter --version
dart --version
flutter pub deps
flutter pub outdated
flutter pub get --enforce-lockfile

它们分别用于:

  • 确认 Flutter/Dart 工具链版本;
  • 查看传递依赖图;
  • 查看可升级依赖和潜在不兼容版本;
  • 要求解析结果符合锁文件约束,避免无意更新。

锁文件不是“安全证明”。它只能减少版本漂移,不能证明某个版本没有漏洞,也不能阻止已被污染的版本。还需要:

  • 固定 Flutter SDK 和原生工具链;
  • 审查新增直接依赖及其维护状态;
  • 避免无必要的 Git 依赖和未固定 revision;
  • 检查插件的 Android/iOS 原生代码;
  • 在 CI 中执行静态分析、测试和依赖扫描;
  • 保护构建缓存,防止不可信任务写入后被后续任务复用。

7.3 插件权限应与功能相称

一个只展示文本的插件却要求通讯录、短信、无障碍或后台定位权限,应视为供应链风险信号。审查 Flutter 插件时至少看:

  • pubspec.yaml 的平台声明;
  • Android AndroidManifest.xml
  • iOS Info.plist 和 entitlements;
  • 原生网络请求、动态加载和 WebView;
  • 是否收集设备标识、剪贴板和日志;
  • 依赖是否包含二进制 SDK;
  • 版本更新是否有异常权限变化。

依赖越多,攻击面越大,但不能用“完全不使用第三方包”作为绝对答案;关键是理解依赖承担的功能、权限和升级责任。

7.4 构建与发布凭据

CI 中的这些数据都应视为高价值 Secret:

  • Android keystore 密码和私钥;
  • Apple API key、证书和 profile;
  • 商店上传 token;
  • 云服务凭据;
  • 代码签名服务访问令牌;
  • 崩溃平台上传 token。

安全的构建流程应满足:

  1. 拉取受信任且固定版本的源代码;
  2. 使用固定工具链构建;
  3. 在受保护环境注入签名材料;
  4. 构建日志自动脱敏;
  5. 构建完成后销毁临时凭据;
  6. 产出制品、校验和、版本号和提交号;
  7. 在发布前验证签名、包名、权限和环境配置;
  8. 保留回滚所需的上一版本制品。

不要把 keystore、.p12.mobileprovision、API token 或生产配置提交到 Git。即使后来删除,Git 历史、镜像和缓存中仍可能保留它们;已经泄露的凭据应立即撤销或轮换,而不是只删除文件。


8. 发布验证:把安全条件变成可观测检查

安全问题常在发布后才暴露,因此需要把关键属性纳入验收。

8.1 Release 构建检查

至少检查:

flutter build apk --release
flutter build appbundle --release
flutter build ios --release

不同平台的命令和签名流程可能依赖本机 Xcode、Android SDK 和证书配置。构建后验证:

  • release 是否仍包含 debug URL;
  • 是否启用了任意证书接受逻辑;
  • 是否包含测试账号、测试 token 和服务端私钥;
  • Android 是否允许不必要的明文流量;
  • iOS ATS 例外是否有明确理由;
  • WebView 是否允许任意导航;
  • 日志是否输出敏感数据;
  • 包名、Bundle ID、签名和 flavor 是否匹配环境。

不能通过“源码里搜不到字符串”证明没有 Secret,因为 Secret 可能来自构建参数、资源、原生配置或传递依赖。

8.2 故障诊断顺序

遇到“接口不可用”时,按层定位比反复重试更有效:

  1. 应用配置:URL、环境、代理和 flavor 是否正确;
  2. DNS/网络:域名是否可解析、设备是否联网;
  3. TLS:证书链、域名、时间和系统信任是否正常;
  4. HTTP:状态码、重定向、响应头和超时;
  5. 认证:令牌是否过期、刷新是否并发失控;
  6. 授权:服务端是否拒绝角色、资源或业务状态;
  7. 序列化:响应结构是否与客户端模型匹配;
  8. 本地状态:缓存、数据库迁移和 WebView 会话是否污染结果。

例如:

  • HandshakeException 通常先查 TLS,不要直接清除用户数据;
  • 401 通常查令牌和刷新流程,不等于证书错误;
  • 403 通常查服务端授权,不应在客户端把用户改成管理员;
  • JSON 解析错误要保留状态码和安全摘要,不要把完整响应写入日志;
  • 只有旧版无法联网而新版正常,优先怀疑证书固定或 TLS 兼容性;
  • 只有某些企业设备失败,优先检查代理、系统 CA 和设备策略。

8.3 安全与可用性的取舍

安全控制必须有恢复路径:

  • 令牌需要撤销和重新登录机制;
  • 证书固定需要备用 pin 和紧急发布方案;
  • 加密存储需要明确密钥丢失后的数据策略;
  • 数据库迁移需要失败回滚和备份恢复测试;
  • 依赖升级需要灰度和回滚制品;
  • 发布签名需要受控备份和访问审计。

最终目标不是让应用“看起来安全”,而是让每一层的保护条件清晰、失败行为可诊断、凭据可撤销、版本可回滚,并且不把客户端当成服务端的替代品。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。