StreamPDFStreamPDF
返回博客

PDF.js 源码解读: decrypt_stream

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: decrypt_stream

前言

这是「字节与流」层里位置比较特殊的一篇,对象是 src/core/decrypt_stream.js。

前面讲过的 Flate、Brotli、Predictor 都在压缩链路上:输入是编码后的字节,输出是原始字节,目标是还原数据本身。DecryptStream 处在另一条链路——解密链路。它不解压,也不做行预测,只把密文字节按密码算法还原成明文;而还原出来的明文,往往还套着 Flate 或 Brotli,要继续交给后面的过滤器。所以它不是压缩链路的一员,而是压缩链路之前的一道门。

这道门的依据是 PDF 规范第 7.6 节。加密文档里的流与字符串默认都要加密,标准安全处理器根据修订版本与加密字典选择具体算法:较早的修订用 RC4,较新的修订引入 AES,密钥长度从 40 位一直到 256 位。加密单位是对象,每个流和每个字符串都用对象号与 generation 派生出的密钥单独处理。crypto.js 里的 CipherTransform 负责这套派生,本文的主角只负责最后一步:拿到密文块,调用密码对象,交出明文块。

真正把 DecryptStream 放进链路的动作发生在 Parser.readStream:

stream = stream.makeSubStream(startPos, length, dict);
const filter = dict.get('F', 'Filter');
// Streams that explicitly use `/Crypt` are decrypted in the filter chain,
// so avoid applying the default stream cipher up-front.
if (cipherTransform && !this.#hasCryptFilter(filter)) {
  stream = cipherTransform.createStream(stream, length);
}

也就是说,默认解密发生在滤镜之前。只有当 /Filter 数组里显式写了 /Crypt,解析器才会跳过这一步,让解密作为滤镜链的一环参与分派。两条路最终都会构造出 DecryptStream,区别只是构造位置与所选密码过滤器。

decrypt_stream.js 只有 55 行,一个类,一个常量,三个方法。它是这个系列里最短的流之一,但把「流式解密」的几处关键取舍都写在了明面上:块多大、怎么知道最后一块、末块标志交给谁、以及流的身份应该停在哪一层。

一句话概括:它把密文流按 512 字节切片解密,并且只有在确认读到末尾时,才把最后一块标记为 final。

模块总览

全文可以切成五块:

区块关键方法职责
分块常量chunkSize规定每次向底层流索取的字节数
构造constructor保存底层流、字典与解密回调
主循环readBlock预读下一块、判断末块、解密并追加到缓冲
预读缓存#nextChunk保存提前读出的下一块
流身份getOriginalStream返回自身,阻止身份继续向底层透传

它在整条链路里的位置:

PDF 字节
  → Parser.readStream
  → ChunkedStream / Stream
  → CipherTransform.createStream         (crypto.js,选择 RC4 或 AES)
  → DecryptStream(str, length, decrypt)  (本文件)
  → Parser.makeFilter                    (Flate / Brotli / DCT ...)
  → 上层: 对象流 / 内容流 / 图像 / 字体

继承关系是 BaseStream → DecodeStream → DecryptStream。严格来说,解密并不属于「解码」,但 DecryptStream 仍然选择挂在 DecodeStream 下,因为它需要的东西完全一样:一个可增长的输出缓冲、一个读取游标、一个惰性驱动的 getBytes,以及一个由子类实现的 readBlock。复用这套骨架,比自己再写一套读取接口要划算得多。

父类提供的契约很短:readBlock 每次往 buffer 尾部追加一段数据,源耗尽时把 eof 置为真;getBytes 负责按调用方要的长度反复触发它,并在返回时给出从 pos 到末尾的零拷贝视图。DecryptStream 的实现完全落在这个契约内,没有额外的读取方法。

设计要点:密码算法有自己的块边界——AES 固定 16 字节,RC4 逐字节——而调用方要的长度是任意的。DecryptStream 用 512 字节的固定分块把两者隔开,再靠密码对象自身的残余缓冲吸收不整齐的尾巴。流层只管搬运,不试图让每次读取都对齐密码块。

解密为什么在滤镜之前

顺序不是实现偏好,而是格式决定的。

PDF 里的加密作用于「流对象的原始字节」,也就是文件里 stream 与 endstream 之间真正写下的那串数据;而 /Filter 描述的是这串数据经过解密之后,应该如何解压或反序列化。两者描述的其实是同一段数据的两个阶段。一个被加密的 Flate 内容流,在文件里是密文;解密后得到带 zlib 头的 DEFLATE 数据;再经过 FlateStream 才得到页面内容。顺序只能是先解密、后解压。

反过来也走不通。滤镜需要读取数据自身的特征来判断格式,例如 zlib 的两字节头、JPEG 的标记段、ASCII85 的字符集;而密文的开头是 AES 的初始化向量或 RC4 密钥流作用后的随机字节,滤镜在其中找不到任何可信的结构。把解密放在滤镜之后,等于要求解码器先对一个随机字节流做格式判断,这在原理上就不成立。

Parser.readStream 因此把默认解密放在 makeFilter 之前:

  1. 从文件里切出 startPos 与 length 指定的原始数据段。
  2. 若存在加密上下文且滤镜链里没有 /Crypt,先用 CipherTransform.createStream 包一层 DecryptStream。
  3. 再按 /Filter 数组逐层包装解码器。
  4. 上层从最外层读取,实际的数据流方向是「密文 → 明文 → 解压 → 预测还原」。

那为什么还要允许 /Crypt 出现在滤镜链里?因为规范允许生成器把解密显式声明成过滤器,并可用 /DecodeParms 里的 /Name 指定某个命名密码过滤器,或指定 Identity 表示这一段不加密。解析器看到 /Crypt 就跳过默认包装,把解密交给 makeFilter 分派,避免同一段数据被解两次密。两条路最终都会落到 DecryptStream,区别只是谁负责构造、用哪个密码过滤器。

这也解释了 DecryptStream 为什么值得继承 DecodeStream:它不是滤镜链的固定前置,而是可以出现在链中任意位置的普通节点。只要读取接口一致,放在哪里都能工作。

构造与分块粒度

chunkSize 常量

const chunkSize = 512;

这个数字和 AES 的 16 字节块没有直接关系,也不是 PDF 规范规定的读取单位。它是这一层选定的 I/O 粒度,同时服务于三个目标。

第一,控制单次分配的临时内存。512 字节足够小,即使一个几 MB 的图片流也不会因为一次多读而占用额外的大块缓冲;它又足够大,摊薄了每次调用 getBytes 与 decrypt 的函数开销。

第二,与密码块保持亲和。512 是 16 的整数倍,大多数情况下每次取出的数据正好落在 AES 块的边界上,密码对象内部几乎不需要跨调用保留残余。这不是正确性的前提——decryptBlock 本来就会把不完整的块留到下一次——但能让热路径更顺。

第三,给「末块判断」留出空间。要知道当前块是不是最后一块,必须向后看一块;块越小,预读的代价越低,首块数据的延迟也越短。

DecodeStream 的 minBufferLength 默认值同样是 512。两者数值相同,含义不同:一个决定从哪里读,一个决定输出缓冲从多大开始翻倍。它们碰巧一致,读源码时不要把二者当成同一个概念。

constructor 方法

  constructor(str, maybeLength, decrypt) {
    super(maybeLength);
 
    this.stream = str;
    this.dict = str.dict;
    this.decrypt = decrypt;
  }

构造函数只保存三样东西。

maybeLength 交给父类,用来计算输出缓冲的起始容量。它来自流字典的 /Length,是密文长度,不是明文长度。由于 AES 流的前 16 字节是初始化向量,末块还要去掉 1 到 16 字节的填充,明文一定比这个值短。父类把它当作容量参考而不是精确长度,正好合适。

this.stream 是底层密文流,可能是 Stream,也可能是 ChunkedStream,甚至可能是另一层流。this.dict 直接引用底层流的字典,不复制:对象号、generation、加密上下文都挂在字典上,解密之后的明文流仍然需要这些信息来标识自己。

this.decrypt 是一个闭包,由 crypto.js 在构造时捕获一个新建的密码实例。这一点很重要:密码对象是有状态的。RC4 需要维护 256 字节的置换表与两个游标,AES 需要维护密钥、当前 IV 以及不足一块的残余。因此每个 DecryptStream 必须配一个独立的密码实例,绝不能跨流共享,否则密钥流的位置或 IV 会错位,解出来的字节会从某一块开始全部错误。

密码实例的生命周期

CipherTransform.createStream 每次都会执行 const cipher = new Cipher()。即使同一份文档里有两个流使用同一个对象密钥,它们拿到的也是两个独立的密码对象。这样做不是浪费,而是必要:解密是顺序的,密码状态只能对应一条流的一次从头到尾的读取。

这条约束也决定了流的克隆方式。DecodeStream.clone() 会先把当前流读到 eof,再把输出缓冲包成一个普通 Stream 返回。对于 DecryptStream,克隆不是复制密码状态,而是把「未来还没解的部分」提前解完:克隆体与原流从此共享同一份明文字节,底层密文已经耗尽,密码对象不会再收到新的输入。若两个消费者想同时读取同一个加密对象,安全的顺序是让一方读完并克隆出普通 Stream,而不是让两个读取者交错驱动同一个密码对象;若需要真正从头再读一份,则应由上层重新取对象、重新构造 DecryptStream。

主循环: readBlock

readBlock 方法

  readBlock() {
    let chunk = this.#nextChunk ?? this.stream.getBytes(chunkSize);
    if (!chunk.length) {
      this.eof = true;
      return;
    }
    this.#nextChunk = this.stream.getBytes(chunkSize);
    const hasMoreData = this.#nextChunk.length > 0;
 
    const decrypt = this.decrypt;
    chunk = decrypt(chunk, !hasMoreData);
 
    const bufferLength = this.bufferLength,
      newLength = bufferLength + chunk.length,
      buffer = this.ensureBuffer(newLength);
    buffer.set(chunk, bufferLength);
    this.bufferLength = newLength;
  }

这十几行可以按四步读。

第一步,取当前块。 this.#nextChunk ?? this.stream.getBytes(chunkSize) 是一个空值合并:如果上一轮预读留下了缓存,就直接用它;否则向底层流要 512 字节。第一次调用必然走后一个分支。

第二步,判断是否到底。 如果当前块长度为 0,说明底层流已经耗尽,置 eof 并返回。注意这里没有任何解密动作:空块没有意义,AES 与 RC4 对空输入都只会返回空输出,调用它纯属浪费。这也是整个方法唯一设置 eof 的地方。

第三步,提前预读下一块。 在解密当前块之前,先把下一块读进 #nextChunk,再用它的长度是否为零得到 hasMoreData。这一步是全部设计的核心:密码回调需要知道「当前块是不是最后一块」,而只有读完下一块才能回答。多读的这 512 字节不会被丢弃,下一轮直接从 #nextChunk 取出,因此底层流仍然只被顺序消费一次,不需要回退游标。

第四步,解密并追加。 decrypt(chunk, !hasMoreData) 把「是否最后一块」作为第二个参数传下去。返回值可能比输入短:AES 首块会吞掉 16 字节 IV,末块会去掉填充。随后 ensureBuffer 保证输出缓冲够长,set 把明文写到尾部,bufferLength 随之更新。

预读带来一个可以验证的不变量:每个非末块都以 false 调用,末块以 true 调用,且 true 只出现一次。 如果流长度恰好是 512 的整数倍,最后一轮会先取到真实的末块,预读则拿到一个空块,于是末块正确地带上了 true;如果流长度不是整数倍,末块本身就不足 512 字节,预读同样返回空块。两种情况都不需要额外的分支。

还有一个细节:AES 在第一次调用时,如果拿到的数据不足 16 字节,会把它缓存起来并返回空数组。这时 chunk.length 为 0,追加操作什么也不做,bufferLength 不变,父类的循环会继续调用 readBlock,直到攒够一个完整块或者流结束。追加逻辑不需要为这种「零产出」做特殊处理,ensureBuffer 与 set 对零长度都是安全的。

为什么不一次读完

既然最终可能要把整段密文都解密,为什么还要分块?

原因不在密码算法,而在调用链的上游。PDF 的流通常是惰性读取的:一页内容流可能只有几百字节,一张扫描图却可能有几十 MB;如果 readBlock 第一次就把底层流全部读光,那么仅仅为了解析页面开头的一个操作符,也要等待整条流从网络或文件到位。分块让 DecodeStream.getBytes 可以按需驱动,调用方要 64 字节,就只解密到够 64 字节为止。

分块还与 ChunkedStream 的缺数据异常配合得很好。底层数据尚未下载完时,getBytes 会抛 MissingDataException;因为读取是按 512 字节推进的,异常会在真正碰到缺口时才出现,PDFManager 据此只请求缺失的区间,而不是盲目扩大预取范围。解密本身是顺序的,已经解出的前缀不需要重来。

final 标志的去向

readBlock 只负责把 !hasMoreData 传下去,真正解释这个布尔值的是密码对象。crypto.js 在构造流时把回调接成一个闭包:

return new DecryptStream(stream, length, function cipherTransformDecryptStream(data, finalize) {
  return cipher.decryptBlock(data, finalize);
});

回调的第二个参数沿用了 decryptBlock 的命名 finalize。它只做一件事:告诉密码对象「这是最后一段密文,可以收尾了」。

AES 的填充处理

AES 是块密码,工作在 CBC 模式。PDF 使用 PKCS#7 填充:明文长度补齐到 16 的整数倍,补几个字节就写几个几,例如缺 5 字节就补五个 0x05;如果明文本来就是 16 的整数倍,则额外补一整块 16 个 0x10。解密时必须把这部分去掉,否则输出尾部会多出无意义的填充字节。

AESBaseCipher._decryptBlock2 在 finalize 为真时执行这段逻辑:

if (finalize) {
  // undo a padding that is described in RFC 2898
  const lastBlock = result.at(-1);
  let psLen = lastBlock[15];
  if (psLen <= 16) {
    for (let i = 15, ii = 16 - psLen; i >= ii; --i) {
      if (lastBlock[i] !== psLen) {
        // Invalid padding, assume that the block has no padding.
        psLen = 0;
        break;
      }
    }
    outputLength -= psLen;
    result[result.length - 1] = lastBlock.subarray(0, 16 - psLen);
  }
}

它取最后一块的最后一个字节作为填充长度,然后校验:长度必须不超过 16,且最后 psLen 个字节都要等于 psLen。任何一项不满足,就把 psLen 当作 0,保留整块。这是典型的容错读法——宁可多留几个字节,也不因为生成器的填充不规范而让整份文档解密失败。

finalize 为假时,函数完全跳过这段逻辑。原因很直接:只有最后一块才有填充,中间块如果也去填充,会把每块末尾的正常数据误删,输出立刻错位。

RC4 与空密码

RC4 是完全不同的情况:

  decryptBlock(data) {
    return this.encryptBlock(data);
  }

它只有一个参数。DecryptStream 传进来的第二个实参在 JavaScript 里会被直接忽略。RC4 是流密码,密钥流与明文逐字节异或,长度一一对应,没有块的概念,也没有填充需要去掉。无论 finalize 是真是假,输出长度都等于输入长度。

NullCipher 更简单,decryptBlock 直接返回原数据。它用于「声明了加密但实际不加密」或 Identity 密码过滤器的情况,同样不关心 finalize。

把三种密码放在一起看,final 的语义就很清楚了:它不是「最后一块要特殊解密」,而是「可以收尾了」。块密码借此去掉填充,流密码则无事可做。

密码实现算法类型对 finalize 的处理输出长度
NullCipher空变换忽略等于输入
ARCFourCipherRC4 流密码忽略(方法签名只接收一个参数)等于输入
AES128CipherAES-CBC为真时校验并去掉末块 PKCS#7 填充可能比输入少 1 到 16 字节
AES256CipherAES-CBC同上同上

首块的 IV

AES 的密文流开头还有 16 字节初始化向量。decryptBlock 在第一次调用时,如果外部没有显式提供 IV,会先把这 16 字节攒进内部缓冲,攒够之后把它设为 iv,再从第 17 字节开始解密。因此首块的实际输出是「读到的 512 字节减去 16 字节 IV」,后续块才是完整的 512 字节,末块再去掉填充。

这也解释了为什么构造函数拿到的 maybeLength 只能当容量参考:真正的明文长度是密文长度减去 IV、再减去填充,而且填充长度要到解密最后一刻才知道。

流身份: getOriginalStream

getOriginalStream 方法

  getOriginalStream() {
    return this;
  }

方法体只有一行,却是这个文件里最容易被忽略的设计。

BaseStream 的默认实现是向下透传:

  getOriginalStream() {
    return this.stream?.getOriginalStream() || this;
  }

一个包在另一个流外面的解码器,会把「原始流」的询问一路传到最底层,最终返回文件里那段字节所在的流。这对 Flate、Brotli 这类纯解码器是合理的:它们包着的底层流,就是自己的数据来源。

DecryptStream 故意打断了这条链。它不把询问传给底层密文流,而是返回自己。原因是身份的定义在解密层发生了变化:对上层来说,真正可信的「原始字节」是解密后的结果,而不是文件里的密文。继续向下透传,会让调用方拿到错误的字节与错误的身份。

这里的「明文」是相对密码层而言的:它可能仍然是 Flate 或 Brotli 压缩的字节,甚至可能还要经过预测器还原,但已经不是密文。对写回与克隆来说,这正是需要的那一层——保留压缩与滤镜信息,只把密码层剥掉。

两个具体调用点可以说明后果。

writer.writeStream 在序列化流时先解包再取字节:

async function writeStream(stream, buffer, transform) {
  stream = stream.getOriginalStream();
  stream.reset();
  let bytes = stream.getBytes();

随后它按字典里的 /Filter 决定是否重新压缩,最后在写回时用 transform.encryptString 重新加密。如果 getOriginalStream 返回的是底层密文流,写回流程就会把密文再加密一次,得到双重加密的数据,同时字典里的 /Length 也与实际内容不符。返回解密层则恰好相反:这里拿到的是明文(对内层压缩而言仍是压缩字节),重新加密一次就回到正确状态。

编辑器复制对象时也依赖同一条规则。pdf_editor 在克隆流时调用 obj.getOriginalStream().clone();DecodeStream.clone() 会先把流读到 eof,再把输出缓冲包成一个普通 Stream。对 DecryptStream 来说,这个过程产出的是一份完整的明文字节,字典也一并克隆;如果身份透传到密文层,复制出来的对象就会带着密文字节和明文字典,后续任何读取都对不上。

所以 getOriginalStream 的返回自身并不是偷懒,而是明确声明:解密层就是这条流对外的最佳身份,再往下属于密码实现的私事。

与 DecodeStream 的契约: 追加而非替换

DecryptStream.readBlock 用的是「追加」写法:先 ensureBuffer 扩到新长度,再把解密结果 set 到 bufferLength 之后。这与 BrotliStream 的「整体替换」形成对照:

解码器输入与输出的长度关系写入方式原因
DecryptStream逐块、近似一比一追加到缓冲尾部明文分块可用,边解密边供上层读取
FlateStream逐块、输出通常更长追加到缓冲尾部块与块之间是连续的字节流
BrotliStream整段解压,长度事先未知直接替换整个缓冲解码器没有增量接口,一次性给出全部结果

追加的前提是「每块解出来就能直接交给调用方」。解密满足这一点:除了首块的 IV 与末块的填充,块与块之间没有依赖,缓冲区里已经写入的部分不会因为后面的解密而改变。因此上层可以在只解出前几百字节时就开始解析内容流,不必等整条流完成。

替换的前提则是「结果必须一次成型」。Brotli 解码器不接受分段输入,输出长度也只有解完才知道,追加只会带来反复扩容和多余拷贝。两种写法没有优劣,只是各自适配解码器的形态。

追加写法还有一个内存上的好处:ensureBuffer 按 2 的幂扩容,容量增长与已解密的数据量成正比,不会出现「先分配整个明文长度」的峰值。对于大图片流,这一点比省几次拷贝更重要。

代价则是扩容时的旧数据搬迁。按 2 的幂增长之后,累计拷贝量与输出总长度同阶,不会随块数平方增长;对于几百 KB 的字体或内容流,实际搬迁只有几次。相比之下,若试图预估明文长度并一次性分配,就会重新依赖并不可信的 /Length,反而更容易出错。

边界与容错

几个边界情况值得单独说明。

  • 空流。第一次 readBlock 拿到的块长度为 0,直接置 eof 返回,解密回调一次都不会被调用。空明文与空密文在结果上一致,跳过调用没有副作用。
  • 末块不足 16 字节。AES 的 _decryptBlock2 会把不足一块的尾部留在内部缓冲里,即使 finalize 为真也无法解密这几十个字节。最终输出会丢掉这段不完整的尾巴,而不是抛出异常。这是「尽量读出能读的部分」的选择。
  • 填充不合法。如上一节所述,校验失败时按「没有填充」处理,保留整块。对生成器不规范的文档,这比直接报错更有实际价值。
  • 密文太短。若整个流不足 16 字节,AES 连 IV 都攒不够,第一次调用返回空数组,随后流结束,上层得到空明文。调用方按空数据处理,不会卡死。
  • /Crypt 过滤器。当 /Filter 数组里显式包含 /Crypt 时,Parser.readStream 不做默认解密,改由 makeFilter 在链中构造 DecryptStream,并按 /DecodeParms 里的 /Name 选择密码过滤器。两条路的类相同,只是构造时机与所选密码不同。
  • 长度不可信。/Length 只用于缓冲起点;真实明文长度由 IV、块边界与填充共同决定。任何试图用 /Length 直接计算输出位置的代码都会在 AES 文档上出错。

这些情况有一个共同点:错误都发生在数据本身,而不是读取框架。DecryptStream 不负责判断文档是否合法,它只保证在给定字节上按顺序执行密码变换,并把能解出的前缀交出去。判断与策略留给上层,这让同一个类既能服务严格的标准文档,也能在畸形文件上尽量读出内容。

设计要点:这一层把「密码块」与「读取块」彻底解耦。密码对象自己处理不足一块的残余与末块填充,流层只负责按固定粒度搬运字节,并用一次前瞻确定末块。分工清楚之后,无论调用方一次要 1 字节还是整条流,输出都一致。

总结

DecryptStream 是 PDF.js 的解密适配层:把标准安全处理器算出的密文流还原成明文,向上交给滤镜链与解析器。

它在整条链路中的位置

阶段模块输入输出
字节供给Stream / ChunkedStream文件切片、网络分块密文字节
密钥派生CipherTransform加密字典、对象号、generation每对象独立的密码实例
解密DecryptStream密文(含 IV 与填充)明文(可能仍被压缩)
滤镜Parser.makeFilter明文压缩字节解压、预测后的字节
上层消费对象流 / 内容流 / 图像 / 字体原始字节字典、operatorList、像素

用一句话串起来:CipherTransform 决定用什么密钥和算法,DecryptStream 决定按什么节奏把密文变成明文,滤镜链再把明文还原成可用数据。

五条设计原则

  1. 密码块边界与读取边界解耦。流层固定 512 字节搬运,不足一块的残余由密码对象自己缓存。调用方要多少给多少,不必关心 AES 的 16 字节或 RC4 的逐字节特性。
  2. 前瞻一次,末块标志只出现一次。先读下一块再解密当前块,用下一块是否为空判断当前块是否为末块。多读的字节留在 #nextChunk,底层流仍然只被顺序消费一遍。
  3. 边解密边追加,而不是整段替换。块与块之间没有依赖,已写入的明文不会被后续块改写,因此上层可以尽早开始解析;缓冲按需翻倍,避免预留整段明文的内存峰值。
  4. 身份在解密层截断。getOriginalStream 返回自身,使写回、克隆、编辑都停在明文这一层。再往下是密文,不属于上层应当看到的「原始字节」。
  5. 容错优先于报错。填充校验失败时保留整块,末块不完整时保留已解出的前缀,/Length 只当容量参考。损坏或不规范的文档尽量读出可用部分,而不是让整份文件失败。

至此,从文件字节到明文压缩字节的这段路补齐了。加密文档里真正被加密的往往不是语义数据,而是压缩后的流;所以解密必须发生在滤镜之前,DecryptStream 与 FlateStream 才会以这种顺序叠在一起。理解了这个顺序,再回头看 Parser.readStream 里那句「显式 /Crypt 时跳过默认解密」的注释,就能明白它是在避免同一段数据被解密两次。

StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.

相关文章