E2E 便携式加密文件导入导出功能

December 21, 2025
9 min read
By devshan

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

功能: .e2e 便携式加密文件的导入导出
涉及模块: Go 后端 (crypto, api)、Flutter 客户端 (api_client, app_state, files_page)


一、需求背景

我更希望能够将已加密存储的文件导出为独立的便携式加密文件(.e2e 格式),可以:

  1. 使用自定义密码加密,独立于主系统密钥
  2. 方便分享给他人或备份到其他位置
  3. 后续可以在任何 E2EEPAN 客户端导入

二、方案设计

2.1 文件格式设计

设计了独立的 E2E 便携式加密文件格式:

┌─────────────────────────────────────────────────────┐
│ Magic (8 bytes): "E2EEPAN1" │
├─────────────────────────────────────────────────────┤
│ Version (1 byte): 0x01 │
├─────────────────────────────────────────────────────┤
│ Salt (16 bytes): 随机盐值 │
├─────────────────────────────────────────────────────┤
│ Nonce (12 bytes): AES-GCM nonce │
├─────────────────────────────────────────────────────┤
│ Encrypted Payload (变长): │
│ - 原始文件内容 │
│ - 内嵌元数据 (JSON): name, mimeType, size │
├─────────────────────────────────────────────────────┤
│ Auth Tag (16 bytes): AES-GCM 认证标签 │
└─────────────────────────────────────────────────────┘

2.2 密钥派生方案选择

选项对比:

方案优点缺点
Argon2id抗 GPU/ASIC 攻击、更安全Go 标准库不支持,需要 cgo
PBKDF2-SHA256纯 Go 实现、跨平台安全性略低于 Argon2id
scrypt内存硬化、安全需要额外依赖

最终选择: PBKDF2-SHA256

  • 迭代次数: 100,000 次
  • 输出密钥: 32 bytes (AES-256)
  • 理由: 便携式文件需要最大兼容性,PBKDF2 是业界标准,100k 迭代提供足够安全性

与主系统区分:

  • 主系统使用 Argon2id(更高安全性,适合长期存储)
  • E2E 便携格式使用 PBKDF2(最大兼容性,适合分享)

2.3 加密 Payload 结构

{
  "meta": {
    "name": "原始文件名.jpg",
    "mimeType": "image/jpeg", 
    "size": 123456
  },
  "data": "<base64 encoded file content>"
}

实际实现使用二进制格式:

[4 bytes: meta JSON length] [meta JSON] [file data]

三、后端实现

3.1 crypto.go 新增内容

// E2E 便携式加密文件格式常量
const (
    E2EMagicBytes    = "E2EEPAN1" // 8 bytes
    E2EVersion       = 1
    E2ESaltSize      = 16
    E2EPBKDF2Iter    = 100000 // PBKDF2 迭代次数
)
 
// E2EMeta E2E文件内嵌的元数据
type E2EMeta struct {
    Name     string `json:"name"`
    MimeType string `json:"mimeType"`
    Size     int64  `json:"size"`
}
 
// DeriveKeyPBKDF2 使用 PBKDF2-SHA256 从密码派生密钥
func DeriveKeyPBKDF2(password string, salt []byte) []byte {
    return pbkdf2.Key([]byte(password), salt, E2EPBKDF2Iter, KeySize, sha256.New)
}
 
// ExportToE2E 将文件导出为 E2E 便携式加密格式
func ExportToE2E(data []byte, meta E2EMeta, password string) ([]byte, error)
 
// ImportFromE2E 从 E2E 便携式加密文件导入
func ImportFromE2E(e2eData []byte, password string) ([]byte, E2EMeta, error)

3.2 server.go API 端点

// 路由注册
api.POST("/e2e/export/:id", s.exportToE2E)
api.POST("/e2e/import", s.importFromE2E)

导出流程 (exportToE2E):

  1. 验证 S3 和解锁状态
  2. 从分块存储读取并解密文件
  3. 使用密码和 PBKDF2 派生密钥
  4. 使用 AES-GCM 加密整个文件+元数据
  5. 返回 .e2e 格式的字节流

导入流程 (importFromE2E):

  1. 接收上传的 .e2e 文件和密码
  2. 验证 Magic 和版本
  3. 使用密码解密,提取原始文件和元数据
  4. 生成新 fileID,重新分块加密存储到 S3
  5. 关键: 更新元数据索引 (updateMetadataIndex)
  6. 返回新文件的元数据

四、客户端实现

4.1 API 客户端 (api_client.dart)

/// 导出文件为 E2E 便携式加密格式
Future<ApiResult<Uint8List>> exportToE2E({
  required String fileId,
  required String password,
  void Function(int received, int total)? onProgress,
  CancelToken? cancelToken,
}) async
 
/// 从 E2E 便携式加密文件导入
Future<ApiResult<FileMetadata>> importFromE2E({
  required Uint8List fileData,
  required String password,
  required String targetPath,
  String? filename,
  void Function(int sent, int total)? onProgress,
  CancelToken? cancelToken,
}) async

4.2 UI 入口 (files_page.dart)

导出入口: 文件菜单 → “加密导出” 导入入口: 上传菜单 → “导入加密文件”


五、问题与修复历程

5.1 app_toast.dart LateInitializationError

问题: Android 运行时报错 LateInitializationError: Field '_entry' has not been initialized

原因分析:

// 原代码
late OverlayEntry _entry;
 
void show() {
  _entry = OverlayEntry(...);  // 这里才初始化
  ...
}
 
// 但在 showAppToast 中
toast.show();  // 先初始化
_updateToastPositions(overlay);  // 这里访问 _entry,但如果并发可能还没初始化

修复:

OverlayEntry? _entry;  // 改为可空类型
 
void _updatePosition(int index) {
  _positionIndex = index;
  _entry?.markNeedsBuild();  // 安全调用
}

5.2 导出文件名隐私保护

问题: 导出的文件名使用原始文件名,可能泄露隐私

解决: 使用随机 8 位小写字母作为文件名

String generateRandomFileName(String extension) {
  final random = Random.secure();
  final name = String.fromCharCodes(
    List.generate(8, (_) => random.nextInt(26) + 97), // a-z
  );
  return '$name.$extension';
}

5.3 统一保存路径

问题: 下载和导出使用不同的保存路径逻辑

解决: 统一使用 AppState.getResolvedDownloadDirPath()

Future<String> getResolvedDownloadDirPath() async {
  if (_downloadDirPath != null && _downloadDirPath!.isNotEmpty) {
    return _downloadDirPath!;  // 自定义路径
  }
  
  // Android: 使用公共下载目录
  if (io.Platform.isAndroid) {
    return '/storage/emulated/0/Download/E2EPan';
  }
  
  // 其他平台: 使用 path_provider
  final downloads = await getDownloadsDirectory();
  if (downloads != null) {
    return '${downloads.path}/E2EPan';
  }
  final docs = await getApplicationDocumentsDirectory();
  return '${docs.path}/E2EPan';
}

注意: getDownloadsDirectory() 在 Android 上返回应用私有目录,需要硬编码公共路径。

5.4 导入后成为游离文件

问题: 导入成功后文件出现在”游离文件”而非正常目录

原因: 后端 importFromE2E 没有更新元数据索引

修复: 添加 updateMetadataIndex() 调用

metadata := FileMetadata{
    ID:            fileID,
    Name:          e2eMeta.Name,
    Path:          targetPath,  // 指定的目标路径
    Size:          e2eMeta.Size,
    EncryptedSize: encryptedSize,
    IsDir:         false,
    MimeType:      e2eMeta.MimeType,
    CreatedAt:     now,
    UpdatedAt:     now,
}
 
if err := s.updateMetadataIndex(ctx, &metadata); err != nil {
    c.JSON(http.StatusInternalServerError, gin.H{"error": "update metadata failed"})
    return
}

5.5 Windows 兼容性

问题: 桌面端使用 withData: true 读取文件,大文件会占用大量内存

优化:

final isDesktop = io.Platform.isWindows || io.Platform.isLinux || io.Platform.isMacOS;
final result = await FilePicker.platform.pickFiles(
  type: FileType.any,
  withData: !isDesktop,  // 桌面端使用 path,移动端使用 bytes
);
 
// 桌面端从文件路径读取
if (isDesktop && pickedFile.path != null) {
  fileBytes = await io.File(pickedFile.path!).readAsBytes();
} else {
  fileBytes = pickedFile.bytes;
}

六、接口复用改造

6.1 原始问题

最初实现的导入/导出是独立的异步操作:

  • 没有进度条
  • 无法中断
  • 不出现在传输列表
  • 与上传/下载逻辑重复

6.2 设计决策

方案对比:

方案导出导入
完全独立简单实现无法复用
复用下载/上传队列最大复用需要扩展 Job 类
新建独立队列清晰分离代码重复

最终方案:

  • 导出: 扩展 _DownloadJob,复用下载队列
  • 导入: 创建 _ImportJob,独立队列但复用上传并发控制

6.3 导出改造

扩展 _DownloadJob:

class _DownloadJob {
  final String id;
  final FileMetadata file;
  final String? localRelativeDir;
  final CancelToken cancelToken;
  
  // 导出相关字段
  final bool isExport;
  final String? exportPassword;
  
  _DownloadJob({
    required this.id,
    required this.file,
    this.localRelativeDir,
    this.isExport = false,
    this.exportPassword,
  }) : cancelToken = CancelToken();
}

新增 enqueueExport():

void enqueueExport(FileMetadata file, {required String password}) {
  // ... 创建传输记录
  _downloadQueue.add(
    _DownloadJob(
      id: id,
      file: file,
      isExport: true,
      exportPassword: password,
    ),
  );
  _downloadJobs[id] = _downloadQueue.last;
  _tryStartNextDownload();
}

修改 _startDownloadJob():

if (job.isExport && job.exportPassword != null) {
  // 导出模式:使用随机文件名
  final fileName = generateRandomFileName('e2e');
  result = await api.exportToE2E(
    fileId: job.file.id,
    password: job.exportPassword!,
    onProgress: onProgress,
    cancelToken: job.cancelToken,
  );
  // 保存到下载目录
  final saveDir = await getResolvedDownloadDirPath();
  await io.File('$saveDir/$fileName').writeAsBytes(result.data!);
} else {
  // 正常下载模式
  result = await api.downloadFile(...);
}

6.4 导入改造

创建 _ImportJob:

class _ImportJob {
  final String id;
  final Uint8List fileData;
  final String fileName;
  final String password;
  final String targetPath;
  final CancelToken cancelToken;
  // ...
}

新增队列和方法:

final List<_ImportJob> _importQueue = [];
final Map<String, _ImportJob> _importJobs = {};
 
void enqueueImport({...}) { /* 加入队列 */ }
void _tryStartNextImport() { /* 复用上传并发控制 */ }
void _startImportJob(_ImportJob job) { /* 执行导入 */ }

进度追踪设计:

void onProgress(int sent, int total) {
  // 上传阶段占 0-90%,后端处理占 90-100%
  final prog = (sent / total) * 0.9;
  // ...
}
 
// 上传完成后更新为 "处理中" (90%)
_transfers[idx] = _transfers[idx].copyWith(
  status: TransferStatus.finishing,
  progress: 0.9,
);
 
// 后端返回成功后更新为 100%

6.5 取消支持

修改 cancelTransfer():

void cancelTransfer(String id) {
  // 检查上传队列
  // 检查下载队列
  // 检查导入队列(新增)
  final queueImportIdx = _importQueue.indexWhere((j) => j.id == id);
  if (queueImportIdx >= 0) {
    _importQueue.removeAt(queueImportIdx);
    _importJobs.remove(id);
    _updateTransferStatus(id, TransferStatus.cancelled, '已取消');
    return;
  }
  
  // 检查正在运行的任务,调用 cancelToken.cancel()
  final importJob = _importJobs[id];
  if (importJob != null) {
    importJob.cancelToken.cancel();
  }
}

七、缩略图生成支持

7.1 问题

导入的图片和视频文件需要生成缩略图,与普通上传保持一致的使用体验。

7.2 图片缩略图

无需特殊处理。后端 downloadThumbnail API 在首次请求时自动按需生成:

func (s *Server) downloadThumbnail(c *gin.Context) {
  // 检查 S3 是否已有缩略图
  // 如果没有,读取原图,调用 generateThumbnail(),保存后返回
}

7.3 视频缩略图

问题: 导入时没有本地视频文件路径,只有 job.fileData 字节数组。

解决方案:

  1. 将视频数据写入临时文件
  2. 使用现有的 VideoThumbnailService 生成
  3. 上传缩略图
  4. 删除临时文件
Future<void> _generateAndUploadImportedVideoThumbnail(
  Uint8List videoData,
  String fileId,
  String fileName,
) async {
  io.File? tempFile;
  try {
    // 创建临时文件
    final tempDir = await getTemporaryDirectory();
    final ext = fileName.contains('.') ? fileName.split('.').last : 'mp4';
    tempFile = io.File('${tempDir.path}/import_thumb_$fileId.$ext');
    
    // 写入视频数据
    await tempFile.writeAsBytes(videoData);
    
    // 使用统一的缩略图服务生成
    final thumbData = await VideoThumbnailService().generateFromFile(tempFile.path);
    
    if (thumbData != null) {
      await api.uploadThumbnail(fileId, thumbData);
    }
  } finally {
    // 清理临时文件
    if (tempFile != null && await tempFile.exists()) {
      await tempFile.delete();
    }
  }
}

平台差异:

  • Android: 使用原生 MediaMetadataRetriever
  • Windows/桌面: 使用 FFmpeg(通过 Go 核心调用)

7.4 调用时机

_startImportJob 导入成功后检查文件类型:

// 导入成功
final importedFile = result.data!;
 
// 视频文件导入后,生成并上传缩略图
if (isVideoFile(importedFile.mimeType, importedFile.name)) {
  _generateAndUploadImportedVideoThumbnail(
    job.fileData,
    importedFile.id,
    importedFile.name,
  );
}
 
// 刷新文件列表
await refreshFiles();

八、最终架构总结

8.1 文件格式

.e2e 文件 = Magic + Version + Salt + Nonce + AES-GCM(文件内容 + 元数据)
密钥派生: PBKDF2-SHA256, 100k 迭代

8.2 接口复用情况

功能复用的接口/逻辑
导出队列_downloadQueue, _startDownloadJob
导出保存getResolvedDownloadDirPath(), generateRandomFileName()
导入并发_uploadConcurrency, _runningUploads
传输记录_transfers, db.addTransfer/updateTransfer
进度回调onProgress, onReceiveProgress, onSendProgress
取消机制CancelToken
视频缩略图VideoThumbnailService().generateFromFile()
缩略图上传api.uploadThumbnail()

8.3 平台兼容性

平台默认保存路径文件选择视频缩略图
Android/storage/emulated/0/Download/E2EPanbytesMediaMetadataRetriever
WindowsC:\Users\devshan\Downloads\E2EPanpathFFmpeg
macOS/Linux~/Downloads/E2EPanpathFFmpeg

九、待优化项

  1. 大文件流式处理: ✅ 已解决,使用 MultipartFile.fromFile 流式上传
  2. 导入进度细化: 后端处理阶段(90-100%)无法细分进度
  3. 批量导入: 当前只支持单文件导入
  4. 密码强度检查: 导出时未强制要求密码复杂度

十、关键代码文件

  • core/internal/crypto/crypto.go - E2E 加解密逻辑
  • core/internal/api/server.go - API 端点 (exportToE2E, importFromE2E)
  • client/lib/core/api/api_client.dart - 客户端 API
  • client/lib/core/state/app_state.dart - 队列管理、传输逻辑
  • client/lib/ui/files_page.dart - UI 入口
  • client/lib/core/utils/file_utils.dart - generateRandomFileName()
  • client/lib/core/services/video_thumbnail_service.dart - 视频缩略图服务

十一、大文件导入 OOM 修复(2025-12-21 23:15)

11.1 问题现象

导入 394MB 的 .e2e 文件时,Android 应用闪退,报错:

java.lang.OutOfMemoryError: Failed to allocate a 413030576 byte allocation
at com.mr.flutter.plugin.filepicker.FileUtils.loadData

11.2 原因分析

内存占用链路

FilePicker (withData: true)
↓ 整个文件读入 JVM 堆 (~400MB)
Uint8List fileBytes
↓ 传递给 enqueueImport
_ImportJob.fileData (~400MB)
↓ 传递给 API
MultipartFile.fromBytes (~400MB)
总占用 > 1GB,超过 Android 堆限制 (256MB)

11.3 解决方案

核心原则:从头到尾不将文件内容读入内存,使用文件路径 + 流式上传

修改点

组件原实现新实现
FilePickerwithData: truewithData: false
_ImportJobUint8List fileDataString filePath + int fileSize
enqueueImport()接收 fileData接收 filePath + fileSize
api.importFromE2E()MultipartFile.fromBytes()MultipartFile.fromFile()

11.4 关键代码变更

api_client.dart

// Before
Future<ApiResult<FileMetadata>> importFromE2E({
  required Uint8List fileData,  // 整个文件在内存
  ...
}) async {
  final formData = FormData.fromMap({
    'file': MultipartFile.fromBytes(fileData, ...),  // 内存副本
  });
}
 
// After
Future<ApiResult<FileMetadata>> importFromE2E({
  required String filePath,  // 只传递路径
  ...
}) async {
  final formData = FormData.fromMap({
    'file': await MultipartFile.fromFile(filePath, ...),  // 流式读取
  });
}

_ImportJob 类

// Before
class _ImportJob {
  final Uint8List fileData;  // ~400MB 在内存
  ...
}
 
// After
class _ImportJob {
  final String filePath;   // 只存路径
  final int fileSize;      // 用于进度显示
  ...
}

11.5 内存占用对比

阶段修复前修复后
文件选择~400MB (FilePicker)~0MB (只返回路径)
Job 存储~400MB (fileData)~100B (路径字符串)
API 上传~400MB (fromBytes)~64KB (流式缓冲区)
总计>1GB<1MB

11.6 额外优化

  1. 视频缩略图复用:导入视频后直接使用 _generateAndUploadVideoThumbnail(filePath) 复用现有逻辑
  2. 删除冗余方法:移除 _generateAndUploadImportedVideoThumbnail (不再需要写临时文件)
  3. 超时延长:导入大文件时 sendTimeoutreceiveTimeout 从 10 分钟延长到 30 分钟

11.7 最佳实践参考

搜索结果要点:

  • 使用 MultipartFile.fromFile 而非 fromBytes
  • Dart Streams 流式读取
  • FilePicker 使用 withData: false
  • 上传大文件时避免将整个文件加载到内存

参考来源: