功能: .e2e 便携式加密文件的导入导出
涉及模块: Go 后端 (crypto, api)、Flutter 客户端 (api_client, app_state, files_page)
一、需求背景
我更希望能够将已加密存储的文件导出为独立的便携式加密文件(.e2e 格式),可以:
- 使用自定义密码加密,独立于主系统密钥
- 方便分享给他人或备份到其他位置
- 后续可以在任何 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):
- 验证 S3 和解锁状态
- 从分块存储读取并解密文件
- 使用密码和 PBKDF2 派生密钥
- 使用 AES-GCM 加密整个文件+元数据
- 返回 .e2e 格式的字节流
导入流程 (importFromE2E):
- 接收上传的 .e2e 文件和密码
- 验证 Magic 和版本
- 使用密码解密,提取原始文件和元数据
- 生成新 fileID,重新分块加密存储到 S3
- 关键: 更新元数据索引 (
updateMetadataIndex) - 返回新文件的元数据
四、客户端实现
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,
}) async4.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 字节数组。
解决方案:
- 将视频数据写入临时文件
- 使用现有的
VideoThumbnailService生成 - 上传缩略图
- 删除临时文件
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/E2EPan | bytes | MediaMetadataRetriever |
| Windows | C:\Users\devshan\Downloads\E2EPan | path | FFmpeg |
| macOS/Linux | ~/Downloads/E2EPan | path | FFmpeg |
九、待优化项
大文件流式处理: ✅ 已解决,使用MultipartFile.fromFile流式上传- 导入进度细化: 后端处理阶段(90-100%)无法细分进度
- 批量导入: 当前只支持单文件导入
- 密码强度检查: 导出时未强制要求密码复杂度
十、关键代码文件
core/internal/crypto/crypto.go- E2E 加解密逻辑core/internal/api/server.go- API 端点 (exportToE2E,importFromE2E)client/lib/core/api/api_client.dart- 客户端 APIclient/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 allocationat com.mr.flutter.plugin.filepicker.FileUtils.loadData11.2 原因分析
内存占用链路:
FilePicker (withData: true) ↓ 整个文件读入 JVM 堆 (~400MB) ↓Uint8List fileBytes ↓ 传递给 enqueueImport ↓_ImportJob.fileData (~400MB) ↓ 传递给 API ↓MultipartFile.fromBytes (~400MB) ↓总占用 > 1GB,超过 Android 堆限制 (256MB)11.3 解决方案
核心原则:从头到尾不将文件内容读入内存,使用文件路径 + 流式上传
修改点:
| 组件 | 原实现 | 新实现 |
|---|---|---|
| FilePicker | withData: true | withData: false |
_ImportJob | Uint8List fileData | String 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 额外优化
- 视频缩略图复用:导入视频后直接使用
_generateAndUploadVideoThumbnail(filePath)复用现有逻辑 - 删除冗余方法:移除
_generateAndUploadImportedVideoThumbnail(不再需要写临时文件) - 超时延长:导入大文件时
sendTimeout和receiveTimeout从 10 分钟延长到 30 分钟
11.7 最佳实践参考
搜索结果要点:
- 使用
MultipartFile.fromFile而非fromBytes - Dart Streams 流式读取
- FilePicker 使用
withData: false - 上传大文件时避免将整个文件加载到内存
参考来源:
- Handling Large Files in Flutter
- Dio MultipartFile.fromFile
- Stack Overflow: Flutter upload large file with progress