Skip to content

第十章:端对端加密(E2EE)

10.1 设计背景与核心哲学

为什么需要 E2EE

待办事项是高度私人的数据,用户的计划、工作内容、个人事务全部汇聚于此。在传统的客户端-服务器架构中,服务端数据库存储的是明文,数据库管理员或拥有服务器访问权限的人可以直接读取所有用户数据。即使服务端运营者值得信赖,数据库泄露或服务器被入侵也会直接暴露用户隐私。

E2EE 的引入彻底解决了这个问题:数据在离开客户端之前就已加密,服务端存储和传输的始终是密文,任何人在没有用户密钥的情况下都无法还原数据内容。

核心设计哲学

本地 SQLite = 绝对信任区(存明文)
网络传输 + 云端服务器 = 盲区(存密文)

服务端被彻底降级为"存储乱码的网盘"。这一设计带来两个关键特性:

  • 本地搜索完整保留:SQLite 中存储明文,关键字搜索直接在本地执行,不依赖服务端
  • 多端同步无缝衔接:新设备登录后将云端密文全部拉取到本地解密入库,与现有同步架构完全兼容

与传统 E2EE 的差异

传统端对端加密(如 Signal)面临两大难题:一是多设备密钥分发(需要复杂的密钥交换协议),二是密码重置导致历史数据永久丢失。本方案通过"本地明文基准"设计天然规避了这两个问题:

  • 多设备一致性:相同账号相同密码在任意设备派生出相同密钥,无需设备间密钥交换
  • 密码重置可恢复:只要有任意一台设备持有本地明文,重置密码后即可用新密钥重新加密上传,云端数据得以恢复

10.2 密钥派生方案

算法选型:PBKDF2-HMAC-SHA256

密钥派生函数(KDF)的作用是将用户密码转换为适合加密的密钥。直接使用密码作为密钥存在两个问题:密码长度不固定且熵值低,容易被暴力破解。PBKDF2 通过大量迭代运算,使得每次破解尝试的计算成本显著提高。

Key = PBKDF2(
  password   = 用户明文密码,
  salt       = 用户邮箱(toLowerCase),
  iterations = 310000,
  keyLength  = 32 字节(AES-256)
)

参数说明

迭代次数(310000):派生一次密钥需要可观的计算量。对正常用户而言,只有首次派生或密码变更时才有一次性的短暂延迟,几乎无感知;但对暴力破解攻击者而言,每尝试一个密码都要付出同样的计算成本,极大提高破解成本。迭代次数参考了 OWASP 对 PBKDF2-HMAC-SHA256 的推荐值。

Salt 使用邮箱:Salt 的作用是防止彩虹表攻击,确保相同密码在不同账号下产生不同密钥。使用邮箱而非随机盐的原因是:邮箱是用户账号的唯一标识,不可变,且不需要额外存储——这正是多设备一致性的基础。若使用随机盐则需要在设备间同步盐值,引入额外复杂度。

密钥长度(32 字节):对应 AES-256 的密钥长度要求。

多设备一致性原理

设备 A:PBKDF2("myPassword", "user@email.com") → Key_X
设备 B:PBKDF2("myPassword", "user@email.com") → Key_X(完全相同)

只要用户在两台设备输入相同的账号和密码,两端独立派生出完全相同的密钥,无需任何网络通信或密钥交换。

密钥本地持久化

密钥派生完成后以 Base64 编码存储在 SharedPreferences 中,按 userId 严格隔离:

SharedPreferences key:encryption_key_$userId

App 启动时直接从本地读取,无需每次重新派生(耗时约 0.5 秒)。只有以下情况需要重新派生:

  • 首次在该设备登录此账号
  • 密码重置后 key_version 发生变化

10.3 加密算法

AES-256-GCM 选型

AES-256-GCM(Galois/Counter Mode)是业界推荐的对称加密方案,具备以下特性:

  • 认证加密:GCM 模式同时提供加密和完整性验证,任何篡改都会导致解密失败
  • 随机 IV:每次加密生成随机 12 字节初始化向量,相同明文每次加密结果不同,防止模式分析
  • 性能:现代 CPU 对 AES 有硬件加速支持,加密速度极快

密文格式

ivBase64:ciphertextBase64

例如:

dGhpcyBpcyBhIHRlc3Q=:ZW5jcnlwdGVkY29udGVudA==

IV(12 字节)和密文(含 GCM 认证标签)拼接存储,服务端无需单独存储 IV。

加密字段范围

字段是否加密理由
title核心隐私内容
description核心隐私内容
due_date时间戳,无语义隐私
priority枚举值,无语义隐私
is_completed布尔值,无语义隐私

访客模式兼容

未登录的访客用户没有密钥,加解密逻辑自动跳过,数据以明文形式上传和存储:

dart
if (key == null) return plainText; // 访客直接返回明文
return CryptoService.encrypt(plainText, key);

10.4 数据流

写操作:上传前加密

用户输入明文内容

写入本地 SQLite(存明文)

ApiService._todoToJson()
  ├── 有密钥:encrypt(title, key) → ivBase64:cipherBase64
  └── 无密钥(访客):原样返回明文

POST/PATCH 上传密文到服务端

读操作:下载后解密

服务端返回 JSON(title 为密文)

ApiService._todoFromJson()
  ├── 有密钥 且 isCipherText(title):safeDecrypt(title, key)
  ├── 有密钥 但 不是密文格式:原样显示(旧数据兼容)
  └── 无密钥(访客):原样显示

syncFromServer() 写入本地 SQLite(存明文)

UI 显示明文

离线队列与加密的配合

离线时写入本地 SQLite 的是明文,同时入队 sync_queue。联网重放队列时:

sync_queue 中的 payload(明文 JSON)

SyncService._replayQueue()

调用 ApiService.createTodo / updateTodo

_todoToJson() 在此处加密

上传密文到服务端

队列存储明文而非密文,这样即使密码在离线期间被修改,重放时使用的是当前最新密钥,不会产生版本混乱。

解密失败降级处理

当密文损坏或密钥不匹配时,safeDecrypt 捕获异常并返回占位符,App 不会崩溃:

dart
static String safeDecrypt(String cipherText, Uint8List key) {
  return decrypt(cipherText, key) ?? '[内容无法解密]';
}

10.5 key_version 机制

作用

key_version 是存储在服务端 users 表中的整数,标识当前密钥的"代数"。它解决的核心问题是:如何让客户端知道服务端的密文是用哪一代密钥加密的

生命周期

用户注册 → key_version = 0(初始值)
用户重置密码 → key_version + 1(服务端递增)

key_version 只在服务端生成和修改,客户端永远不自行生成或修改,只从服务端接收并本地存储。

客户端存储(按 userId 隔离)

SharedPreferences key:key_version_$userId

下发时机

场景携带方式
注册注册响应体 data.key_version
登录登录响应体 data.user.key_version
密码重置重置响应体 data.key_version

X-Key-Version 响应头

所有需要认证的 API 接口(todos 的 GET/POST/PATCH/DELETE)在响应头中携带当前 key_version

X-Key-Version: 1

客户端在 _request() 方法中统一拦截:

dart
final serverVersion = int.parse(res.headers['x-key-version'] ?? '0');
final localVersion  = AuthService.instance.keyVersion;

if (serverVersion > localVersion) {
  // 本地密钥已过期,强制退出
  await AuthService.instance.clearSession();
  return ApiResult.error('密码已在其他设备修改,请重新登录', needsReLogin: true);
}

这一机制保证了:密码重置后,持有旧密钥的其他设备在下一次 API 请求时会被立即强制退出,不会出现用旧密钥解密新密文的乱码情况。


10.6 登录时的密钥决策

登录是密钥状态最复杂的时机。saveSession() 在保存 token 的同时,内部完成密钥决策:

dart
// 读取该账号在本地的历史数据
final localKeyVersion = prefs.getInt('key_version_$userId') ?? 0;
final existingKey     = prefs.getString('encryption_key_$userId');

// 需要重新派生:本地无密钥 或 本地版本与服务端不一致
final needsKeyDerive = existingKey == null || localKeyVersion != keyVersion;

两种情况

复用现有密钥needsKeyDerive = false):

  • 本地有该账号的密钥
  • 本地 key_version 与服务端一致
  • 直接加载本地密钥,无需重新派生,登录几乎无延迟

重新派生密钥needsKeyDerive = true):

  • 首次在该设备登录此账号(本地无密钥)
  • 密码已在其他设备重置(key_version 不一致)
  • 调用 deriveAndSaveKey(password, email),耗时约 0.5 秒

知情同意弹窗

首次登录时(e2ee_consent_$userId 未标记)在进入主页前展示一次性知情同意弹窗,用户点击「我已了解」后永久记录,后续登录不再展示。知情同意状态同样按 userId 隔离存储。


10.7 密码重置与密钥轮换

密码重置是 E2EE 中最复杂的流程,需要确保服务端的所有密文与新密钥保持同步。

双重铁闸

在进入密码重置流程之前,系统执行双重铁闸检查,防止危险操作:

铁闸一:本地有无加密密钥

检查当前设备本地是否存有该账号的加密密钥(encryption_key_$userId)。

  • 通过:本地有密钥,说明该设备持有有效的明文基准,可以完成密钥轮换
  • 拦截:本地无密钥(空设备),若允许重置,新密钥将无法解密服务端旧密文,导致其他设备数据变乱码

提示文案:

🔒 安全保护 当前设备未检测到本地加密数据。为防止云端隐私数据被意外锁死,密码重置只能在存有历史数据的设备上操作。请在您的常用设备上打开应用并修改密码。

铁闸二:key_version 本地与云端是否一致

请求 GET /auth/key-version-by-email 获取服务端当前 key_version,与本地对比。

  • 通过:版本一致,本地明文与服务端密文同代
  • 拦截:版本不一致,说明其他设备已改过密码,本地数据是"旧代",若用旧数据重新加密上传,会覆盖服务端更新的密文

提示文案:

⚠️ 同步断层警告 检测到此设备的数据凭证已过期(其他设备曾修改过密码)。当前设备已被禁止重置密码,以防止老旧数据破坏云端最新内容。请先正常登录以同步最新数据,再修改密码。

网络失败处理:铁闸二网络请求失败时,直接拒绝重置,不降级放行。

密钥轮换六步流程

步骤一:同步云端最新数据
  用旧密钥拉取服务端最新密文 → 解密 → 合并到本地 SQLite
  确保本地是最新基准,防止覆盖其他设备的更新

步骤二:提交新密码到服务端
  POST /auth/reset-password(验证码 + 新密码)
  服务端:验证验证码 → 更新密码 → key_version + 1
  服务端返回新 key_version

步骤三:自动用新密码登录获取 token
  POST /auth/login(新密码)
  获取 access_token,用于后续上传重加密数据

步骤四:派生新密钥 Key B
  PBKDF2(新密码, email) → Key B
  持久化到本地(覆盖旧密钥)

步骤五:批量重新加密并上传
  读取本地 SQLite 全部明文待办
  用 Key B 加密 title/description
  逐条 PATCH 上传到服务端,覆盖旧密文

步骤六:清除 session,提示重新登录
  key_version 已在步骤三的 saveSession 中从服务端获取并保存
  clearSession() 清除 token(保留密钥和 key_version)
  提示用户「密码重置成功,请重新登录」

force_logout SSE 广播

步骤二完成后,服务端立即通过 SSE 向该用户的所有其他在线设备广播 force_logout 事件:

服务端:EventBroadcaster.forceLogout(userId, excludeDeviceId: 当前设备)

其他设备收到 SSE force_logout 事件

clearSession() + 跳转登录页

SnackBar 提示:「密码已在其他设备修改,您已被安全退出,请重新登录」

被强制退出的设备重新登录后,saveSession 检测到 key_version 不一致,自动重新派生新密钥,拉取服务端(新密钥加密的)密文正确解密,数据恢复正常。

边界场景

场景处理方式
空设备(无密钥)尝试重置铁闸一拦截,禁止操作
其他设备已改过密码铁闸二拦截,提示先登录同步
铁闸二网络失败直接拒绝,不降级放行
步骤五上传中途断网步骤六不执行,session 保留;重新登录后检测 key_version 变化,重新派生密钥,再次触发 syncAll 补传剩余数据(自愈)
全端清除数据 + 忘记密码云端数据永久无法恢复(极端边界,知情同意已告知用户)

10.8 多账号隔离

问题背景

一台设备可能先后登录多个账号。如果密钥、key_version、知情同意状态使用固定 key 存储,账号切换时会出现 A 账号读到 B 账号密钥的严重错误。

隔离方案

三个用户相关字段全部使用 $userId 后缀隔离:

字段SharedPreferences Key
加密密钥encryption_key_$userId
key_versionkey_version_$userId
知情同意e2ee_consent_$userId

Session 相关字段(access_tokenrefresh_tokenuser_idemailnickname)不隔离,但 emailuser_id 在退出登录时保留不删除,供铁闸检查使用。

账号切换逻辑

dart
// saveSession() 内部
final existingKey = prefs.getString('encryption_key_$userId');
if (existingKey != null && !needsKeyDerive) {
  _encryptionKey = base64.decode(existingKey); // 加载新账号的密钥
} else {
  _encryptionKey = null; // 触发重新派生
}

切换账号时,旧账号的密钥和数据完全不受影响,新账号的密钥独立加载。


10.9 知情同意

触发时机

知情同意弹窗在以下情况展示,且每个账号只展示一次:

  • 新用户完成注册后
  • 用户首次在某台设备登录某个账号后

弹窗设置 barrierDismissible: false,用户必须点击「我已了解」才能关闭,确保用户看到提示内容。

告知内容

隐私保护已启用

您的待办标题和内容已启用端对端加密(E2EE)。

• 数据在本设备加密后上传,服务器无法读取您的内容
• 多设备同步:相同账号相同密码,数据自动互通

⚠️ 重要提示
若您清除了所有设备的应用数据,同时又忘记了登录密码,
云端数据将永久无法恢复。请妥善保管您的登录密码。

Android allowBackup=false

Android 系统默认会将应用数据(包括 SQLite 数据库)备份到 Google Drive 或手机厂商云服务。若本地明文数据库被系统备份,则 E2EE 的本地安全防线会从外部被破坏。

AndroidManifest.xml 中禁用系统备份:

xml
<application
    android:allowBackup="false"
    android:fullBackupContent="false"
    ...>

这确保本地明文数据库不会出现在任何云端备份中,即使手机遗失也不会泄露数据内容。