You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter大文件分段解密报错:无效填充块,求高效加载方案

问题:大AES加密音频文件的流式解密与快速加载(块填充错误)

我有一个AES加密的音频文件,密钥(Key)和初始化向量(IV)存储在sqflite数据库中,可通过DownloadedAudioModel获取,最终需要将解密后的音频加载到audioPlayer中播放。

目前全量解密的代码可以正常运行,但加载10MB的文件耗时过长——推测是因为一次性将所有加密字节读入内存后再解密导致的:

static Future<void> decryptAndLoadFile(
    DownloadedAudioModel downloadedAudioModel) async {
  String password = downloadedAudioModel.key ?? "";
  final ivBase64 = downloadedAudioModel.encIV ?? "";

  final directory = await getDownloadsDirectory();
  final downloadDirectory = Directory(directory!.path);

  final encryptedFile = File(
      '${downloadDirectory.path}/songs/${downloadedAudioModel.songPath}.encrypted');
  final encryptedBytes = await encryptedFile.readAsBytes();
    final key = enc.Key.fromUtf8(password);
    List<int> iv = base64Decode(ivBase64).toList();
    final encrypter = enc.Encrypter(enc.AES(key));

    final decryptedBytes = encrypter.decryptBytes(enc.Encrypted(encryptedBytes),
        iv: enc.IV(Uint8List.fromList(iv)));
    await MyGlobals.audioPlayer.setAudioSource(AudioSource.uri(
        Uri.dataFromBytes(decryptedBytes),
        tag: MediaItem(
            id: downloadedAudioModel.id.toString(),
            title: downloadedAudioModel.songName!,
            artist: downloadedAudioModel.authorName,
            artUri: Uri.file(
                "${downloadDirectory.path}/avatars/${downloadedAudioModel.songAvatar}"))));
}

为了实现更快加载,我尝试通过流分段读取加密字节并解密,但两次尝试都抛出ArgumentError (Invalid argument(s): Invalid or corrupted pad block)异常:

  1. 直接使用openRead()分块读取并解密:
final encryptedFile = File(
    '${downloadDirectory.path}/songs/${downloadedAudioModel.songPath}.encrypted');
encryptedFile.openRead().forEach((element) {
  final decryptedBytes = encrypter.decryptBytes(
      enc.Encrypted(Uint8List.fromList(element)),
      iv: enc.IV(Uint8List.fromList(iv)));
});
  1. 手动循环分块解密并返回流:
static Stream<List<int>> decryptFile(
    DownloadedAudioModel downloadedAudioModel,
    Directory downloadDirectory) async* {
  String password = downloadedAudioModel.key ?? "";
  final ivBase64 = downloadedAudioModel.encIV ?? "";

  final encryptedFile = File(
      '${downloadDirectory.path}/songs/${downloadedAudioModel.songPath}.encrypted');

  final encryptedBytes = await encryptedFile.readAsBytes();

  final key = enc.Key.fromUtf8(password);
  List<int> iv = base64Decode(ivBase64).toList();
  final encrypter = enc.Encrypter(enc.AES(key));

  try {
    int i = 0;
    while (i < encryptedBytes.length) {
      final chunk = encryptedBytes.sublist(
          i, math.min(i + 4096, encryptedBytes.length));
      final decryptedChunk =
          _decryptChunk(encrypter, enc.IV(Uint8List.fromList(iv)), chunk);
      yield decryptedChunk;
      i += 4096;
    }
  } catch (e) {
    if (e is FormatException &&
        e.message.contains('Invalid or corrupted pad block')) {
      yield* Stream.error(
        Exception(
            'Error decrypting file: The provided password is incorrect or the file is corrupted.'),
      );
    } else {
      yield* Stream.error(e);
    }
  }
}

static List<int> _decryptChunk(
    enc.Encrypter encrypter, enc.IV iv, List<int> encryptedChunk) {
  final encrypted = enc.Encrypted(Uint8List.fromList(encryptedChunk));
  try {
    final decryptedChunk = encrypter.decryptBytes(encrypted, iv: iv);
    return decryptedChunk;
  } catch (e) {
    return [];
  }
}

static Future<void> playDecryptedAudio(
    DownloadedAudioModel downloadedAudioModel) async {
  final directory = await getDownloadsDirectory();
  final downloadDirectory = Directory(directory!.path);
  try {
    final decryptedStream =
        decryptFile(downloadedAudioModel, downloadDirectory);

    final decryptedBytes = await decryptedStream.fold<List<int>>(
      [],
      (previousValue, element) => [...previousValue, ...element],
    );
    
    await MyGlobals.audioPlayer.setAudioSource(AudioSource.uri(
        Uri.dataFromBytes(decryptedBytes),
        tag: MediaItem(
            id: downloadedAudioModel.id.toString(),
            title: downloadedAudioModel.songName!,
            artist: downloadedAudioModel.authorName,
            artUri: Uri.file(
                "${downloadDirectory.path}/avatars/${downloadedAudioModel.songAvatar}"))));

    await MyGlobals.audioPlayer.play();
  } catch (e) {
    print('Error playing decrypted audio: $e');
  }
}

请问如何正确实现大文件的流式解密与快速加载?


解决方案

错误原因分析

流式解密失败的核心问题有两个:

  1. AES分组模式的状态丢失:如果使用CBC模式(AES默认常用模式),每个密文块的解密依赖前一个密文块作为IV,不能全程复用初始IV。
  2. 块大小与填充不匹配:AES是分组加密算法,块大小固定为16字节,加密后的总长度是16的整数倍(PKCS7填充)。非16倍数的分块或错误的填充处理会触发校验异常。

正确实现步骤

  1. 流式读取+维护解密状态:通过文件流逐块读取,分块大小设为16的整数倍(比如4096),每解密一块后更新IV为当前密文块的最后16字节。
  2. 直接流式加载到audioPlayer:使用AudioSource.stream替代Uri.dataFromBytes,实现边解密边播放,无需将整个文件加载到内存。
  3. 单独处理最后一块的填充:最后一块解密后自动移除PKCS7填充,避免数据冗余。

完整代码示例

import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:just_audio/just_audio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:encrypt/encrypt.dart' as enc;

// 流式解密函数
static Stream<List<int>> decryptFileStream(
    DownloadedAudioModel downloadedAudioModel) async* {
  final key = enc.Key.fromUtf8(downloadedAudioModel.key ?? "");
  final initialIvBytes = base64Decode(downloadedAudioModel.encIV ?? "");
  final encrypter = enc.Encrypter(enc.AES(key, mode: enc.AESMode.cbc));

  final directory = await getDownloadsDirectory();
  final encryptedFile = File(
      '${directory!.path}/songs/${downloadedAudioModel.songPath}.encrypted');
  
  const chunkSize = 4096; // 必须是16的倍数(4096/16=256)
  final fileLength = await encryptedFile.length();
  var currentIvBytes = initialIvBytes;
  var remainingBytes = fileLength;

  final stream = encryptedFile.openRead();
  await for (final chunk in stream) {
    remainingBytes -= chunk.length;
    Uint8List encryptedChunk = Uint8List.fromList(chunk);

    // 处理最后一块:完整解密并自动移除PKCS7填充
    if (remainingBytes == 0) {
      final decrypted = encrypter.decryptBytes(
        enc.Encrypted(encryptedChunk),
        iv: enc.IV(currentIvBytes),
      );
      yield decrypted;
    } else {
      // 中间块:只解密不处理填充,更新IV为当前密文块的最后16字节
      final decrypted = encrypter.decryptBytes(
        enc.Encrypted(encryptedChunk),
        iv: enc.IV(currentIvBytes),
        padding: null,
      );
      currentIvBytes = encryptedChunk.sublist(encryptedChunk.length - 16);
      yield decrypted;
    }
  }
}

// 播放解密后的音频
static Future<void> playDecryptedAudio(
    DownloadedAudioModel downloadedAudioModel) async {
  final directory = await getDownloadsDirectory();

  try {
    final decryptedStream = decryptFileStream(downloadedAudioModel);

    // 直接用流加载音频,避免内存堆积
    await MyGlobals.audioPlayer.setAudioSource(
      AudioSource.stream(
        decryptedStream,
        tag: MediaItem(
          id: downloadedAudioModel.id.toString(),
          title: downloadedAudioModel.songName!,
          artist: downloadedAudioModel.authorName,
          artUri: Uri.file(
              "${directory!.path}/avatars/${downloadedAudioModel.songAvatar}"),
        ),
      ),
    );

    await MyGlobals.audioPlayer.play();
  } catch (e) {
    print('Error playing decrypted audio: $e');
  }
}

关键注意事项

  • 加密模式一致性:确保加密时使用的AES模式(如CBC)和填充方式(PKCS7)与解密代码完全匹配。
  • 分块大小要求:必须设置为16的整数倍,否则中间块解密会失败。
  • 内存优化:AudioSource.stream让audioPlayer边接收数据边播放,不会将整个解密后的音频加载到内存,大幅提升大文件加载速度。
  • IV状态维护:CBC模式下,每个后续块的IV必须是前一个密文块的最后16字节,否则解密数据会混乱。

内容的提问来源于stack exchange,提问作者SultanKingGD

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.22 22:37:01