Skip to content

⛔ 本文档已被取代(2026-08-02),不可作为当前实现的依据

权威文档:key-lifecycle.md

本文写于 RSA 时代(下方"基于代码版本"里列的 e2ee_transfer_service.dartshamir_secret_sharing.dart 今天都已不存在)。以下内容已确认不成立

  • §1.1 把 RSA-2048 / RSA-OAEP-256+AES-256-GCM 当作主协议 —— v2 已 Olm-only cutover,RSA 降为仅解密(ADR 24)。
  • §1.2 存储清单漏掉了几乎全部真实密钥材料(Olm account/session pickle、 Megolm inbound session、OTK、fallback key、SQLCipher DB key)。
  • §5.2 社交恢复(Shamir Secret Sharing)—— 已从代码删除。
  • §4 设备迁移引用的 e2ee_transfer_handler —— 已删除(迁移 38)。

保留原文仅为追溯当时的决策语境。

E2EE 密钥旋转策略

E2EE Key Rotation Policy

文档版本 / Doc Version: 1.0.0 最后更新 / Last Updated: 2026-05-27 基于代码版本 / Based on Code: e2ee_key_service.dart, e2ee_service.dart, e2ee_settings.dart, e2ee_transfer_service.dart, e2ee_local_backup_service.dart, shamir_secret_sharing.dart, imboy/src/api/e2ee_handler.erl


1. 当前密钥生命周期 / Current Key Lifecycle

1.1 密钥算法规格 / Key Algorithm Specification

参数来源
非对称算法RSA-2048E2EEKeyService.rsaKeySize = 2048
公钥指数65537 (F4)E2EEKeyService.publicExponent = 65537
对称算法AES-256-GCMe2ee_suite: 'RSA-OAEP-256+AES-256-GCM'
AES 密钥长度256 bit (32 字节)_secureRandomBytes(32)
GCM Nonce 长度12 字节_secureRandomBytes(12)
密钥 ID 格式kid_{8位十六进制}_generateKeyId()
设备 ID 格式{8hex}-{8hex}-{8hex}_generateDeviceId()

1.2 密钥存储位置 / Key Storage Location

客户端(Client)
├── 私钥 (RSA private key PEM)  → flutter_secure_storage(Keychain on macOS/iOS)
├── 公钥 (RSA public key PEM)   → flutter_secure_storage
├── device_id                   → flutter_secure_storage
├── key_id                      → flutter_secure_storage
└── key_created_at (ISO 8601)   → flutter_secure_storage

服务端(Server)
└── user_device 表              → PostgreSQL(device_id, public_key, key_id, uid)

1.3 当前密钥过期策略 / Current Expiry Policy

代码层面目前无强制过期时间配置。 从代码中提取的关键常量:

常量说明
公钥缓存 TTL30 分钟E2EEService._cacheTtlMs = 30 * 60 * 1000
备份密钥派生迭代次数310,000 次E2EECryptoService.pbkdf2Iterations = 310000
备份文件格式版本1E2EECryptoService.formatVersion = 1
传输会话过期由服务端控制expires_at 字段由 e2ee_transfer_handler 返回

注意 / Note: E2EESettings.isEnabled() 当前强制返回 false,E2EE 由后端 policy(EncryptionModeService)统一控制。密钥本身在 Keychain 中持久存储,不自动过期。


2. 旋转触发条件 / Rotation Trigger Conditions

2.1 主动触发(用户/系统发起)

触发条件描述处理入口
设备更换新设备首次安装,本地无密钥E2EEKeyService.hasKey() 返回 false → 自动生成
重新安装应用Keychain 数据未迁移,密钥丢失同上;旧设备公钥仍在服务端,需执行旋转清理
手动触发用户在"安全设置"中主动更新密钥调用 E2EEKeyService.generateKeyPair()
设备迁移完成通过 QR 码传输到新设备后确认E2EETransferService.confirmTransfer()
备份恢复.enc 文件导入密钥E2EELocalBackupService.importBackup()
社交恢复完成收集足够 Shamir 分片后重建ShamirSecretSharing.combineShares()

2.2 被动触发(安全事件)

安全事件处理策略
密钥泄露怀疑立即调用 E2EEKeyService.deleteKey() 删除本地密钥,重新生成,通过 report_device_key 上报新公钥
服务端通知密钥已失效监听 pull_notifications 返回的密钥变更通知,清除对应用户缓存:E2EEService.clearUserKeyCache(uid)
对端公钥缓存过期(30分钟)_isCacheExpired() 自动触发重新获取,无需手动干预
ACK 超时 / 解密持续失败_e2ee_failed: true + _e2ee_reason 标记;可触发 retryDecryptFailedMessage() 重试

2.3 后端策略触发

客户端通过 EncryptionModeService.refresh()/v1/app/policy 拉取 e2ee_mode capability,镜像为本地 EncryptionMode

e2ee_mode(后端 capability)客户端 EncryptionMode行为
disabledplaintextE2EE API 拒绝(守卫返回 ERR_FEATURE_DISABLED),无加密
optionalplaintextE2EE API 可用,但不强制加密;本地开关决定(当前 isEnabled() 强制 false → 实际明文)
requiredstrictE2ee强制端到端加密,密钥不存在则拒绝发送
compliancecomplianceE2ee双密钥加密:设备密钥 + 合规审计密钥(compliance-audit did)

合法值集合以后端 imboy_policy_normalize.erl:185 normalize_e2ee_mode/2 为权威。详见 docs/compliance/e2ee-policy.md §1.2。


3. 旋转流程 / Rotation Flow

3.1 标准旋转流程

用户触发旋转


1. 生成新 RSA-2048 密钥对
   E2EEKeyService.generateKeyPair()
   ├── Web 平台: Web Crypto API
   └── 移动/桌面: pointycastle (Isolate 中运行,不阻塞 UI)


2. 生成新 key_id(kid_{8hex})和 created_at 时间戳


3. 原子写入 Keychain(flutter_secure_storage)
   Future.wait([
     savePrivateKey(pem),
     savePublicKey(pem),
     setDeviceId(did),
     setKeyId(kid),
     setKeyCreatedAt(ts),
   ])


4. 上报新公钥到服务端
   POST /v1/e2ee/report_device_key
   { device_id, device_type, device_name, public_key, key_id }


5. 服务端更新 user_device 表
   e2ee_logic:report_device_key(...)
   → 服务端通知该用户所有好友清除公钥缓存


6. 清除本地对端公钥缓存
   E2EEService.clearAllKeyCache()


7. 旧密钥处理
   ├── 旧私钥:Keychain 覆盖写入(步骤 3 已完成)
   ├── 旧公钥:服务端覆盖更新(步骤 5 已完成)
   └── 旋转前发送的历史消息:无法用新密钥解密(RSA 非对称,历史消息密文绑定旧公钥)
         → 建议:在旋转前导出本地备份(步骤可选)


8. 旋转完成,新密钥生效

3.2 旧消息处理策略 / Historical Message Handling

由于每条消息使用独立的 AES-256 密钥(由发送方用接收方公钥包装),密钥旋转后:

  • 旋转后收到的消息:使用新公钥加密,可正常解密。
  • 旋转前收到并已解密的消息:明文已存入 SQLite,不受影响。
  • 旋转前收到但未解密的消息:AES 密钥用旧公钥包装,新私钥无法解开,显示 🔒 [加密消息无法解密]_e2ee_failed: true, _e2ee_reason: 'no_device_key''decrypt_error')。

建议:在触发旋转前,通过 retryDecryptFailedMessage() 批量处理所有待解密消息,或提示用户旋转后历史加密消息将不可读。


4. 设备迁移流程 / Device Migration Flow

基于 E2EETransferService 的 QR 码传输方案:

旧设备(Source Device)              新设备(Target Device)
        │                                      │
        │ 1. 用户在旧设备发起迁移              │
        │    createTransfer(toUid, encBundle)  │
        │    → POST /v1/e2ee/transfer/create   │
        │    → 获得 session_id + expires_at    │
        │                                      │
        │ 2. 生成 QR 码                        │
        │    generateQRCodeData(session_id)    │
        │    {"type":"e2ee_transfer",          │
        │     "session_id":"..."}              │
        │                                      │
        │                     3. 新设备扫描 QR  │
        │                        parseQRCodeData│
        │                                      │
        │                     4. 接受传输       │
        │                        acceptTransfer(│
        │                          session_id, │
        │                          new_device_id│
        │                        )             │
        │                        → 解密密钥包   │
        │                        → 保存到 Keychain│
        │                                      │
        │                     5. 确认完成       │
        │                        confirmTransfer│
        │                        (session_id)  │
        │                                      │
        │ 6. 新设备上报新公钥                  │
        │    report_device_key(new_did, ...)    │

安全说明:

  • encryptedKeyBundle 使用目标用户当前在服务端注册的公钥加密(RSA-OAEP)。
  • 传输会话有 expires_at 时效限制(服务端控制)。
  • 传输完成后旧设备密钥仍有效,用户需手动在旧设备上删除(E2EEKeyService.deleteKey())或在服务端撤销旧设备注册。

5. 密钥备份与恢复 / Key Backup and Recovery

5.1 本地文件备份(E2EELocalBackupService)

文件格式 (imboy_e2ee_backup_<timestamp>.enc):

[文件头 32 bytes]
  Magic: "IMBOYBKP" (8 bytes)
  Version: 1          (2 bytes)
  AlgorithmId: 0x0001 (2 bytes)
  PBKDF2 Iterations: 310,000 (4 bytes)
  Salt Length: 16    (2 bytes)
  IV Length: 12      (2 bytes)
  Tag Length: 16     (2 bytes)
  Reserved: 6 bytes
[Salt 16 bytes]
[IV 12 bytes]
[GCM Auth Tag 16 bytes]
[AES-256-GCM 密文]
[用户备注长度 4 bytes]? + [备注内容]?

密钥派生: PBKDF2-HMAC-SHA256,310,000 次迭代,16 字节随机 Salt。

密码要求: 最低 8 位(_validatePassword)。建议使用强密码(12 位以上,含大小写+数字)。

导出流程:

dart
final path = await E2EELocalBackupService.exportBackup(
  password: userPassword,
  privateKey: privateKeyPem,
  publicKey: publicKeyPem,
  deviceId: deviceId,
  keyId: keyId,
  userNotes: '主手机 2026-05-27',
);
await E2EELocalBackupService.shareBackup(path);

恢复流程:

dart
final data = await E2EELocalBackupService.importBackup(
  filePath: selectedFilePath,
  password: userPassword,
);
// data 包含: private_key, public_key, device_id, key_id, created_at
// 恢复后需重新上报公钥

完整性验证: SHA-256 校验和嵌入密文中,解密后验证(calculateChecksum)。

5.2 社交恢复(Shamir Secret Sharing)

使用 ShamirSecretSharing 实现 (k, n) 门限方案:

参数
素数域RFC 3526 MODP Group 14(2048 位安全素数)
最大秘密长度256 字节(适配 RSA-2048 私钥)
推荐配置(3, 5):5 个受信任联系人,3 个即可恢复
随机源Fortuna CSPRNG + Random.secure() 32 字节种子

分片流程:

dart
// 将私钥 PEM 转为字节后分片
final secretBytes = utf8.encode(privateKeyPem) as Uint8List;
final shares = ShamirSecretSharing.splitSecret(secretBytes, n: 5, k: 3);
// shares[i] = {'index': i+1, 'x': int, 'y': BigInt}
// 将每个分片加密后发送给对应受信联系人保管

恢复流程:

dart
// 收集至少 3 个分片
final collected = [share1, share3, share5]; // 任意 3 个
final secretBytes = ShamirSecretSharing.combineShares(collected);
final privateKeyPem = utf8.decode(secretBytes);

安全验证(内置):

  • 分片格式验证(x > 0, 0 < y < prime)
  • 分片索引唯一性验证(防重复分片攻击)
  • 恢复长度合理性验证(≤ 256 字节)

与后端社交恢复 API 联动:

POST /v1/e2ee/recovery/start
{ "device_id": "...", "method": "social_recovery" }
→ e2ee_recovery_logic:start_auto_recovery(...)

6. 安全建议 / Security Recommendations

场景建议周期
常规使用每 90 天主动旋转一次
高安全要求用户每 30 天旋转
检测到异常登录后立即旋转
设备修复/系统重装后立即旋转

当前代码无自动周期旋转逻辑,建议在 app_upgrade_orchestrator.dart 或专用定时任务中实现:检查 key_created_at 距今是否超过阈值,超过则提示用户旋转。

6.2 强制旋转场景 / Mandatory Rotation Scenarios

以下场景必须立即执行密钥旋转:

  1. 私钥文件/Keychain 数据疑似泄露
  2. 设备被盗或丢失(需同时在服务端撤销该 device_id 对应的公钥注册)
  3. 应用被卸载后重新安装(Keychain 可能残留旧密钥,新安装时应强制重新生成)
  4. 检测到 _e2ee_reason: 'no_device_key' 的解密失败持续超过阈值(对端可能已旋转,本端缓存未清除)
  5. 后端 compliance_e2ee 模式激活(需确认本端密钥已上报且合规公钥已获取)

6.3 运营安全注意事项 / Operational Security Notes

项目建议
备份密码强度推荐 12 位以上,含大小写字母+数字(当前代码仅验证 8 位最低要求)
备份文件存储不应存储在与密钥相同的设备上;推荐邮件或独立云盘
Shamir 分片分发每个分片应由不同联系人保管,不应集中存储
传输会话 QR 码生成后应在 5 分钟内使用(具体时效由服务端 expires_at 决定)
旋转后通知联系人服务端 report_device_key 会自动下发通知,客户端收到后清除缓存:E2EEService.clearUserKeyCache(uid)
AES 会话密钥重用每条消息使用独立的 AES 密钥(_secureRandomBytes(32))和 Nonce(_secureRandomBytes(12)),无需关注

6.4 待完善事项 / Known Gaps

缺口建议
无自动旋转提醒app_upgrade_orchestrator 中增加 key_created_at 过期检查(建议 90 天)
备份密码强度校验宽松_validatePassword 仅检查 8 位长度,建议增加复杂度规则
旧设备密钥未自动撤销迁移完成后需手动撤销旧设备注册,建议在 confirmTransfer 后自动触发
E2EESettings.isEnabled() 强制 false当前 E2EE 完全由后端 policy 控制,客户端开关形同虚设;建议在 policy = optional 时恢复客户端开关语义

IMBoy — 企业私有化即时通讯平台