StreamPDFStreamPDF
返回博客

PDF.js 源码解读: crypto

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: crypto

前言

这是 PDF.js 源码解读系列里偏「密码学」的一篇,对象是 src/core/crypto.js,全文 1360 行。它是 src/core 里少见的、以算法为主体而不是以数据结构为主体的文件:没有页面树,没有语法解析,只有字节置换、密钥扩展、哈希比较,以及一层把 PDF 加密字典翻译成运行时分派的小工厂。

前面的文章按层次拆过流、解析器、xref 与 document。到了加密这一层,问题不再是「字节在哪里」,而是「这些字节能不能读、用哪把钥匙读、读到第几块时该收尾」。另一篇讲 DecryptStream 的文章只关心怎么把密文块喂进来;本篇关心的是块背后的那把钥匙,以及它是怎么被算出来的。

PDF 的加密是文档级开关、对象级加密。trailer 里的 /Encrypt 指向一个加密字典,它声明算法族、修订版本、密钥长度、密码过滤器,以及两份用口令保护过的校验值。真正的加密粒度却是对象:每个间接对象用自己的对象号与 generation 派生一把对象密钥,字符串与流各自解密。同一个文档里,不同对象的内容不会用同一段密钥流。

口令有两份,规范里叫用户密码与所有者密码。文档里不保存任何一个密码的明文:/O 与 /U 各自保存一段 32 字节的校验值,较新的修订还在后面追加校验盐与密钥盐。用户密码能打开文档,所有者密码除了能打开文档,还能解开「用户密码」,从而改变权限。权限位 /P 只是一份声明,阅读器可以选择遵守,但它不构成密码学上的保护。

机制上有两代。较早的修订用 RC4:密钥 40 位起步,后来扩展到 128 位,逐字节异或,状态只有 256 字节。较新的修订引入 AES:/V 4 的密码过滤器可以选 AESV2(AES-128,CBC 模式),/V 5 固定为 AES-256。AES 是块密码,所以流与字符串都要处理 16 字节对齐、IV 与 PKCS#7 填充。

这些算法没有交给浏览器 WebCrypto。原因不是「不想用」,而是不能:标准安全处理器需要同步、可分段、对任意密钥长度工作的原语;R 2 到 R 4 的密钥派生还要做几十轮 MD5,并用 RC4 反复加密同一段校验数据;R 6 的 Algorithm 2.B 需要 AES-128-CBC 且不做填充,只取整块。WebCrypto 的异步接口、算法白名单和填充策略都不匹配。于是 PDF.js 自己实现了 RC4、AES-128、AES-256,并把 MD5、SHA-256、SHA-384、SHA-512 拆到独立模块里。

一句话概括:crypto.js 把 PDF 规范第 7.6 节的「标准安全处理器」拆成四层——密码原语、口令校验、对象密钥、按对象分派的变换门面。

读这个文件最容易犯的错,是按密码学教材的顺序读。源码的顺序刚好相反:先给底层原语,再给 PDF 特有的口令算法,最后才是把加密字典翻译成运行时分派的工厂。本文按源码顺序讲,但每一层都会说明它在 PDF 字典里对应哪个键。

版本说明:本篇严格按当前仓库这一版 crypto.js 讲解。它导出 AES128Cipher、AES256Cipher、ARCFourCipher、CipherTransform、CipherTransformFactory、PDF17、PDF20;没有导出 NullCipher、PDFBase、utf8PasswordToBytes。工厂里没有名为 buildCipherTransform 的方法,对外的是 createCipherTransform(num, gen),对象密钥由私有方法 #buildObjectKey 派生;变换对象上的字符串方法是 decryptString 与 encryptString,流方法是 createStream。/V 只接受 1、2、4、5,/V 3 会直接抛错。MD5、SHA-256、SHA-384、SHA-512 都不在本文件实现,而是从 calculate_md5.js、calculate_sha256.js、calculate_sha_other.js 引入。读源码时不要套用记忆里的其它版本 API。

模块总览

crypto.js 可以切成七块:

区块行范围关键类与方法职责
对称密码原语53-111ARCFourCipher、NullCipherRC4 的 KSA/PRGA 与「不加密」的恒等实现
AES 基类113-525AESBaseCipherS 盒、T 表、轮函数、CBC 链接与填充处理
AES 密钥扩展527-654AES128Cipher、AES256Cipher10 轮与 14 轮的轮密钥生成
口令校验算法656-756PDFBase、PDF17、PDF20R5/R6 的 O/U 校验、文件密钥解包、Algorithm 2.B
变换门面758-855CipherTransform按密码过滤器分派、流式解密、字符串加解密
口令编码857-864utf8PasswordToBytesJavaScript 字符串转 UTF-8 字节
安全处理器工厂866-1350CipherTransformFactory解析加密字典、校验口令、派生文件密钥、生成变换

对外真正被别的模块调用的入口只有五个:

入口调用方作用
new CipherTransformFactory(dict, fileId, pwd)XRef.parse每个文档一个,解析加密字典并算出文件密钥
factory.encryptionKeyCipherTransformFactory 自身文件级密钥,供对象密钥派生使用
factory.createCipherTransform(num, gen)XRef.fetchUncompressed每个间接对象一个变换
transform.decryptString / transform.createStreamParser读字符串与流
transform.encryptStringWriter写回字符串与流

它在整条链路里的位置:

trailer /Encrypt
  → XRef.parse
  → new CipherTransformFactory(dict, fileId, password)
  → #prepareKeyData / #createEncryptionKey20        (口令校验,得到 encryptionKey)
  → XRef.fetchUncompressed(ref)
  → createCipherTransform(num, gen)                 (按对象号派生对象密钥)
  → Parser(cipherTransform)
      → decryptString                               (字符串,整段解密)
      → createStream → DecryptStream → decryptBlock (流,分块解密)
  → Dict / BaseStream

写回是另一条对称路径:

Writer.writeObject
  → encrypt.createCipherTransform(ref.num, ref.gen)
  → transform.encryptString(string)
  → 写出加密后的字符串 / 流

这两条路径共用同一个工厂,但工厂本身不保存「当前对象」。对象号与 generation 是每次调用现传的参数;这也是为什么 CipherTransformFactory 只有一个私有字段 #fileId,而 CipherTransform 只缓存「过滤器名到密码构造器」的映射。加密状态被刻意压缩到最小:文件密钥一份,对象密钥按需现算,密码实例按流/字符串新建。

设计要点:这个文件的分层不是按「算法难度」分的,而是按生命周期分的。文件密钥随文档存活;对象密钥随一次 fetch 存活;密码实例(RC4 的 256 字节置换表、AES 的 IV 与残余块)随一条流或一个字符串存活。三层里任何一层被提升到更长生命周期,都会破坏流式解密的顺序性。后文会反复看到这条约束如何影响缓存、绑定与实例化。

加密字典与类的映射

工厂的入口

PDF 的加密参数不在页面里,而在 trailer 的 /Encrypt 条目上。XRef.parse 读出 trailer 之后,先把 /Encrypt 解析成字典,再取 /ID 数组的第一个元素作为文件标识,最后连用户口令一起交给工厂:

if (encrypt instanceof Dict) {
  const ids = trailerDict.get('ID');
  const fileId = ids?.length ? ids[0] : '';
  // The 'Encrypt' dictionary itself should not be encrypted, and by
  // setting `suppressEncryption` we can prevent an infinite loop inside
  // of `XRef_fetchUncompressed` if the dictionary contains indirect
  // objects (fixes issue7665.pdf).
  encrypt.suppressEncryption = true;
  this.encrypt = new CipherTransformFactory(encrypt, fileId, this.pdfManager.password);
}

这段代码里有三个信号,正好对应工厂的三个构造参数:

  1. /Encrypt 字典本身必须不参与解密。字典里的 /O、/U 是校验值,/CF 可能包含间接对象;如果为了读加密字典又要先解密,就会形成死循环。suppressEncryption 就是这条链路的断路器,#prepareKeyData 里对 /CF 也做了同样的处理。
  2. fileId 来自 /ID[0],允许为空。它参与密钥派生:同一个密码、同一份权限,只要文件标识不同,派生出的文件密钥就不同。这是防止「一份密钥打天下」的盐。
  3. pdfManager.password 可能为空。空口令不代表「不加密」,而是「用户还没输入」;工厂要能区分「缺口令」与「口令错」,这两种情况在 UI 上对应不同的提示。

一个 V 4、R 4、AESV2 的加密字典长这样(结构示意,二进制值用文字代替):

// 加密字典的结构示意,不是可直接运行的 PDF 解析结果
const encryptDict = {
  Filter: 'Standard',
  V: 4,
  R: 4,
  Length: 128,
  CF: {
    StdCF: {
      CFM: 'AESV2',
      AuthEvent: 'DocOpen',
      Length: 16,
    },
  },
  StmF: 'StdCF',
  StrF: 'StdCF',
  EFF: 'StdCF',
  O: '32 字节校验值 + 8 字节校验盐 + 8 字节密钥盐',
  U: '32 字节校验值 + 8 字节校验盐 + 8 字节密钥盐',
  P: -3904,
  EncryptMetadata: true,
};

注意 Length 的单位是位,而 /CF 里某个处理器自己的 Length 在规范里是字节。PDF.js 对两者都做了纠偏,这一点后文讲构造函数时会展开。

字典键到字段

工厂构造函数做的事,本质上是把这张表搬进实例字段:

加密字典键源码字段 / 局部变量含义谁在使用
/FilterfilterName安全处理器名称,必须是 Standard构造函数入口校验
/Valgorithm加密机制版本,只允许 1、2、4、5构造函数、createCipherTransform
/Rrevision口令算法修订号,常见 2、3、4、5、6#prepareKeyData、#createEncryptionKey20
/LengthkeyLength文件密钥长度,单位位#prepareKeyData、#buildObjectKey
/CFcf密码过滤器字典,仅 V 大于等于 4 时读取resolveCipher
/StmFstmf流的默认密码过滤器名CipherTransform 的 streamFilterName
/StrFstrf字符串的默认密码过滤器名CipherTransform 的 stringFilterName
/EFFeff内嵌文件的默认密码过滤器名,缺省等于 StmFCipherTransform.embeddedFilterName
/OownerBytes、ownerPassword所有者口令校验值;前 32 字节参与校验,后 16 字节是盐两条口令路径
/UuserBytes、userPassword用户口令校验值;前 32 字节参与校验,后续字节是盐两条口令路径
/OEownerEncryption用所有者密钥加密过的文件密钥,仅 V 5#createEncryptionKey20
/UEuserEncryption用用户密钥加密过的文件密钥,仅 V 5#createEncryptionKey20
/Permsperms权限的加密副本,仅 V 5传给 #createEncryptionKey20,本版未使用
/Pflags权限位,按 32 位有符号整数读取#prepareKeyData 的哈希输入
/EncryptMetadataencryptMetadata元数据流是否加密,仅 V 4、V 5 有意义哈希输入、Catalog 的取流参数

/P 出现在哈希里这一点经常被忽略。它意味着:只改权限位而不改密码,校验值就对不上。权限声明因此被绑进了密钥派生,而不是一张可以随手编辑的配置表。

/V 与 /R 是两条独立的坐标

初学者容易把 /V 与 /R 当成同一个「版本号」。它们描述的是两件不同的事:

  • /V 决定机制:用不用密码过滤器、用 RC4 还是 AES、文件密钥多长。
  • /R 决定口令算法:/O、/U 怎么算,校验值有多长,密钥派生做几轮。

同一个机制可以配不同的修订号,所以工厂里是两条分支叠加:先看 algorithm 走哪条密钥派生路径,再由 revision 决定哈希算法与迭代次数。把支持范围画成表:

/V机制每对象密钥常见 /R文件密钥长度
1RC4,固定 40 位有25 字节
2RC4,长度由 /Length 指定有35 到 16 字节
4密码过滤器,CFM 可为 V2、AESV2、None有4通常 16 字节
5AES-256,固定使用文件密钥无5、632 字节

/V 3 在规范里是「RC4 128 位」的早期写法,但这一版 PDF.js 直接拒绝,构造函数只接受 1、2、4、5。读源码时不要以为漏写了分支:createCipherTransform 末尾那段注释写的是 algorithms 1 and 2,V 3 根本到不了那里。

口令算法的支持范围:

/R口令算法校验值布局密钥长度
2Algorithm 2:MD5 一次,RC4 一次/O、/U 各 32 字节40 位
3Algorithm 2 的加强版:MD5 五十次,RC4 二十轮/O、/U 各 32 字节40 到 128 位
4与 R 3 相同,增加密码过滤器与 /EncryptMetadata/O、/U 各 32 字节通常 128 位
5SHA-256 校验,盐分离,/OE、/UE 加密文件密钥/O、/U 各 48 字节,外加 /OE、/UE256 位
6SHA-256 起步,Algorithm 2.B 迭代,密码先做 SASLprep同 R 5256 位

从字典到运行时对象

把上表落到类上,映射关系是这样的:

字典信息运行时承载
Filter StandardCipherTransformFactory.filterName
V、R、Lengthalgorithm、revision、派生出的 encryptionKey
CF、StmF、StrF、EFFcf、stmf、strf、eff,最终进入 CipherTransform
O、U、OE、UE、P构造函数里的局部字节数组,喂给 #prepareKeyData 或 #createEncryptionKey20
CFM V2ARCFourCipher 加对象密钥
CFM AESV2AES128Cipher 加对象密钥
CFM AESV3 或 V 5AES256Cipher 加文件密钥
CFM None 或缺省NullCipher
密码构造函数参数,只参与校验,不保存

这张表解释了一个常见疑惑:为什么 V 5 没有「每对象密钥」。 因为 AES-256 的 32 字节文件密钥已经足够强,规范直接用它加密所有流与字符串,不再做对象号派生;/CF 里的 AESV3 只是声明「这里用 AES-256」,并不改变密钥来源。V 4 则相反,文件密钥通常只有 16 字节,必须靠对象号与 generation 派生,避免同一密钥直接复用到所有对象。

设计要点:加密字典是「声明式」的,类结构是「分派式」的。字典只描述参数,不描述流程;工厂把参数翻译成一次性的文件密钥,再把「怎么选密码、怎么派生对象密钥」封装成一个闭包交给 CipherTransform。这样 CipherTransform 不需要知道 /V、/R、/Length,只需要知道「给我一个过滤器名,我还你一个密码构造器」。

ARCFourCipher:256 字节的状态机

RC4 是 PDF 里最老的密码,也是这个文件里最短的实现。它是流密码:没有块边界,没有填充,密钥流由一张 256 字节的置换表逐字节产生,明文与密钥流异或就是密文。PDF 从 R 2 一直用到 R 4,密钥长度从 5 字节到 16 字节不等;R 4 的 CFM V2 也仍然是 RC4。

KSA:用密钥打乱 256 字节

RC4 分两步:先用密钥初始化置换表(KSA),再逐字节产生密钥流(PRGA)。构造函数只做第一步:

class ARCFourCipher {
  a = 0;
 
  b = 0;
 
  constructor(key) {
    const s = new Uint8Array(256);
    const keyLength = key.length;
 
    for (let i = 0; i < 256; ++i) {
      s[i] = i;
    }
    for (let i = 0, j = 0; i < 256; ++i, ++j) {
      const tmp = s[i];
      j = (j + tmp + key[i % keyLength]) & 0xff;
      s[i] = s[j];
      s[j] = tmp;
    }
    this.s = s;
  }

逐行看:

  1. 先建一张恒等置换:s[i] = i,256 个字节刚好覆盖 0 到 255。
  2. j 初始为 0,每一轮加上当前字节 s[i] 与密钥字节 key[i % keyLength]。密钥通常比 256 短,所以按 i % keyLength 循环取用。
  3. & 0xff 把累加结果截断回一个字节。这不是「取模优化」的随意写法,而是 RC4 定义的一部分:所有下标都必须落在 0 到 255。
  4. 交换 s[i] 与 s[j]。256 轮之后,s 是 0 到 255 的一个排列。

这段循环没有写成「先算 j 再交换」的教科书形式,而是把 s[i] 先存进 tmp,再写 s[j]、写 s[i]。顺序必须如此:s[i] = s[j] 会覆盖原值,如果先写 s[i] 再读 s[j] 就会读错。源码用的是最稳的临时变量写法。

keyLength 在这个类里没有校验。它可能是 5、16,也可能是 32;KSA 只关心「非空」和「按长度循环」。密钥是否合法由上游的 CipherTransformFactory 负责,密码原语只负责算。

PRGA:每次调用推进一点

初始化之后,a 与 b 两个游标作为实例字段保存,encryptBlock 每次推进一段:

  encryptBlock(data) {
    let a = this.a,
      b = this.b;
    const s = this.s;
    const n = data.length;
    const output = new Uint8Array(n);
    for (let i = 0; i < n; ++i) {
      a = (a + 1) & 0xff;
      const tmp = s[a];
      b = (b + tmp) & 0xff;
      const tmp2 = s[b];
      s[a] = tmp2;
      s[b] = tmp;
      output[i] = data[i] ^ s[(tmp + tmp2) & 0xff];
    }
    this.a = a;
    this.b = b;
    return output;
  }

这段是标准的 PRGA:

  • a 每字节加一,保证遍历整张表。
  • 取 s[a],加到 b 上,再取 s[b]。
  • 交换 s[a] 与 s[b],让表持续变化。
  • 输出字节等于输入字节异或 s[(s[a] + s[b]) & 0xff]。源码里 tmp 与 tmp2 就是交换前的两个值,省掉了再次查表。

两个游标在函数开头读入局部变量,结尾写回实例。这是热路径上的常见写法:局部变量避免每次属性访问,循环结束再同步一次状态。如果中途抛错,状态不会写回,下一次调用会从旧位置继续;不过这段循环不会抛错,所以不必考虑回滚。

状态必须跨调用保留。 这是 RC4 与 AES 最大的不同:AES 可以按块独立调用,RC4 的密钥流位置完全由 a、b 与当前置换表决定。一条 10 MB 的流被拆成 512 字节块喂进来时,第 2 块必须接着第 1 块的 a、b 继续算;如果每次重新构造密码对象,第 2 块就会从头开始产生密钥流,解出来的内容是错的。这也是后文 CipherTransform 缓存「构造器」而不是「实例」的根本原因。

decryptBlock 与 encrypt

RC4 的加密与解密是同一个运算:密文再异或同一段密钥流就回到明文。源码没有为解密另写一份循环:

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

三个名字对应两种调用习惯:

方法参数行为谁在用
encryptBlock字节数组推进密钥流并异或内部、口令算法里的 RC4 轮
decryptBlock字节数组(第二参数被忽略)转调 encryptBlockDecryptStream、decryptString
encrypt字节数组转调 encryptBlockencryptString、对象密钥校验

decryptBlock 的签名只接收一个参数,DecryptStream 传来的 finalize 会被 JavaScript 静默忽略。这不是疏漏,而是语义上确实不需要:RC4 没有块,也没有填充要收尾。

RC4 还有一个容易忽略的性质:同一把密钥不能复用两条流的开头。如果两份内容用同一把对象密钥从 a = 0、b = 0 开始加密,它们开头的密钥流完全相同,异或两份密文就能消掉密钥流。PDF 用对象号与 generation 派生对象密钥,正是为了避免这种复用;而每次 createStream 都新建密码实例,也是为了每条流都从干净状态开始。

设计要点:RC4 的实现只有 49 行,却把「流密码的状态性」写得非常清楚。构造函数只建立初始状态,真正的推进发生在 encryptBlock;加密与解密共享同一个循环,因为异或自逆。任何试图把密码对象做成无状态工具函数的封装,都会在分块读取时失效。

NullCipher:把「不加密」也做成对象

NullCipher 只有八行:

class NullCipher {
  decryptBlock(data) {
    return data;
  }
 
  encrypt(data) {
    return data;
  }
}

它的语义是恒等:输入什么,返回什么,连数组都不复制。decryptBlock 同样忽略第二个参数,因为「不加密」既没有块边界,也没有填充。

为什么需要一个「什么都不做」的类,而不是在上层判断 null?因为解密链路上有太多位置需要统一接口:

  • CFM 缺失,或 CFM 明确写 None,resolveCipher 返回 NullCipher。
  • /StmF、/StrF 缺省为 Identity,对应的过滤器没有 CFM,也落到 NullCipher。
  • DecryptStream 的回调只认 decryptBlock,不关心背后是 RC4、AES 还是恒等。

如果上层到处写「有密码对象就解密,没有就跳过」,那么 Parser、DecryptStream、CipherTransform 每一处都要重复这个判断,而且很容易在某一条路径上漏掉。把「不加密」建模成一个密码对象之后,调用方只看见统一的 decryptBlock(data, finalize),分支被收敛到工厂的 resolveCipher 一处。

密码类类型实例状态输入输出长度关系典型来源
ARCFourCipher流密码256 字节置换表,两个游标相等V 1、V 2、CFM V2
AES128Cipher块密码176 字节轮密钥、16 字节残余、IV整块输入输出,末块可去填充CFM AESV2
AES256Cipher块密码240 字节轮密钥、16 字节残余、IV整块输入输出,末块可去填充V 5、CFM AESV3
NullCipher恒等无相等,且不复制CFM None、Identity

AESBaseCipher:一张表驱动的 AES

AES 是块密码,固定 16 字节一块,状态是一个 4 乘 4 的字节矩阵,按列优先摆放:state[0] 到 state[3] 是第一列,state[4] 到 state[7] 是第二列,依此类推。PDF 用 CBC 模式,所以除了轮函数,还要处理 IV 链接、分块缓冲与末块填充。

AESBaseCipher 把 AES-128 与 AES-256 的共同部分全部收进基类:

  • 四张查表:S 盒、逆 S 盒、逆混合列的 32 位表、倍乘表。
  • 加密与解密的轮函数。
  • CBC 的缓冲、IV 链接与解密填充处理。
  • 首次调用时从密文头部读 IV 的惰性逻辑。

子类只提供两样东西:轮数与轮密钥扩展。这样 128 位与 256 位之间的差异被压到最小,读源码时可以清楚看到「AES 本身」与「密钥长度」是两件事。

四张表

第一张是 S 盒,256 个字节,负责 SubBytes 与 InvSubBytes:

_s = new Uint8Array([
  0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
  0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0,
  // 其余 240 项省略
]);

第二张是它的逆表 _inv_s,同样 256 项。两张表都不是运行时算出来的,而是直接写成字面量;这是用体积换启动时间,也是密码实现的常规做法。

第三张是 _mix,类型是 Uint32Array,256 项。它不是普通的 S 盒,而是把逆混合列的系数打包进一个 32 位整数:

_mix = new Uint32Array([
  0x00000000, 0x0e090d0b, 0x1c121a16, 0x121b171d, 0x3824342c, 0x362d3927,
  // 其余 250 项省略
]);

第 0 项是 0,第 1 项是 0x0e090d0b,第 2 项是 0x1c121a16。把 0x0e090d0b 按字节拆开就是 0e、09、0d、0b,正是逆混合列矩阵第一列的四个系数与输入字节 1 在 GF(2^8) 上的乘积;第 2 项则是每个系数乘 2 的结果。也就是说,_mix[x] 一次给出「输入字节 x 对四行输出的全部贡献」,轮函数再把四个这样的 32 位值按字节错位异或,就完成了一次 InvMixColumns。

第四张是 _mixCol,256 个字节,用类字段的表达式直接算出来:

_mixCol = Uint8Array.from({ length: 256 }, (_, i) => (i < 128 ? i << 1 : (i << 1) ^ 0x1b));

这是 AES 的 xtime:乘以 2。小于 128 时左移一位即可;大于等于 128 时左移会溢出 8 位,需要再异或 0x1b(AES 规定的不可约多项式低字节)。这张表供加密轮里的 MixColumns 使用。

常量类型项数作用
_sUint8Array256SubBytes,字节替换
_inv_sUint8Array256InvSubBytes,解密时的反替换
_mixUint32Array256InvMixColumns 的列贡献,按 32 位打包
_mixColUint8Array256xtime,加密时乘以 2

构造与两个抽象点

基类的构造函数只做两件事:拒绝直接实例化,以及准备一个 16 字节的输入缓冲:

  constructor() {
    if (
      (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) &&
      this.constructor === AESBaseCipher
    ) {
      unreachable("Cannot initialize AESBaseCipher.");
    }
    this.buffer = new Uint8Array(16);
    this.bufferPosition = 0;
  }
 
  _expandKey(cipherKey) {
    unreachable("Cannot call `_expandKey` on the base class");
  }

buffer 用来暂存不足 16 字节的输入;bufferPosition 记录缓冲里已有多少字节,构造函数里置 0。_expandKey 是唯一的抽象方法,基类调用它只会触发 unreachable。

子类在构造时补上三个字段:

字段AES-128AES-256含义
_cyclesOfRepetition1014轮数,决定轮函数循环几次
_keySize160224最后一轮轮密钥在扩展密钥里的字节偏移
_key176 字节240 字节扩展后的轮密钥

源码注释把 _keySize 写成 bits,但它在 _decrypt 与 _encrypt 里是直接当字节下标用的:key[this._keySize] 开始取最后一轮的 16 字节。读的时候按「字节偏移」理解,不要被注释误导。

解密轮函数

_decrypt 是这个文件里最长的单个方法之一,完整实现如下:

  _decrypt(input, key) {
    let t, u, v;
    const state = new Uint8Array(16);
    state.set(input);
 
    // AddRoundKey
    for (let j = 0, k = this._keySize; j < 16; ++j, ++k) {
      state[j] ^= key[k];
    }
    for (let i = this._cyclesOfRepetition - 1; i >= 1; --i) {
      // InvShiftRows
      t = state[13];
      state[13] = state[9];
      state[9] = state[5];
      state[5] = state[1];
      state[1] = t;
      t = state[14];
      u = state[10];
      state[14] = state[6];
      state[10] = state[2];
      state[6] = t;
      state[2] = u;
      t = state[15];
      u = state[11];
      v = state[7];
      state[15] = state[3];
      state[11] = t;
      state[7] = u;
      state[3] = v;
      // InvSubBytes
      for (let j = 0; j < 16; ++j) {
        state[j] = this._inv_s[state[j]];
      }
      // AddRoundKey
      for (let j = 0, k = i * 16; j < 16; ++j, ++k) {
        state[j] ^= key[k];
      }
      // InvMixColumns
      for (let j = 0; j < 16; j += 4) {
        const s0 = this._mix[state[j]];
        const s1 = this._mix[state[j + 1]];
        const s2 = this._mix[state[j + 2]];
        const s3 = this._mix[state[j + 3]];
        t =
          s0 ^
          (s1 >>> 8) ^
          (s1 << 24) ^
          (s2 >>> 16) ^
          (s2 << 16) ^
          (s3 >>> 24) ^
          (s3 << 8);
        state[j] = (t >>> 24) & 0xff;
        state[j + 1] = (t >> 16) & 0xff;
        state[j + 2] = (t >> 8) & 0xff;
        state[j + 3] = t & 0xff;
      }
    }
    // InvShiftRows
    t = state[13];
    state[13] = state[9];
    state[9] = state[5];
    state[5] = state[1];
    state[1] = t;
    t = state[14];
    u = state[10];
    state[14] = state[6];
    state[10] = state[2];
    state[6] = t;
    state[2] = u;
    t = state[15];
    u = state[11];
    v = state[7];
    state[15] = state[3];
    state[11] = t;
    state[7] = u;
    state[3] = v;
    for (let j = 0; j < 16; ++j) {
      // InvSubBytes
      state[j] = this._inv_s[state[j]];
      // AddRoundKey
      state[j] ^= key[j];
    }
    return state;
  }

这段代码执行的是 AES 的等价逆密码,而不是把加密步骤简单倒放。顺序如下:

  1. 先用最后一轮轮密钥做 AddRoundKey,下标从 _keySize 开始。
  2. 中间轮从 _cyclesOfRepetition - 1 递减到 1,每轮依次做 InvShiftRows、InvSubBytes、AddRoundKey(第 i 轮密钥)、InvMixColumns。
  3. 最后一轮只做 InvShiftRows、InvSubBytes、AddRoundKey(第一轮密钥),没有 InvMixColumns。

等价逆密码的关键在于把 InvMixColumns 放在 AddRoundKey 之后。这样解密可以直接使用加密时生成的同一组轮密钥,不需要对中间轮密钥再做一次逆混合列变换。代价是轮密钥的使用顺序与加密相反,结构上表现为「先加最后一轮密钥,再逐轮往前」。

InvShiftRows 的实现是按行循环右移:第 1 行移 1 位,第 2 行移 2 位,第 3 行移 3 位。状态按列优先存储,所以第 1 行是下标 1、5、9、13,第 2 行是 2、6、10、14,第 3 行是 3、7、11、15。源码没有按下标循环,而是把三条链的交换展开写死:

t = state[13];
state[13] = state[9];
state[9] = state[5];
state[5] = state[1];
state[1] = t;

这是第 1 行的循环右移:13 → 9 → 5 → 1,最后把原 13 放回 1。第 2 行同理,只是用两个临时变量 t、u 处理四元素循环;第 3 行用 t、u、v 处理三步轮换。展开写死的好处是省掉循环与取模,代价是代码看起来有点绕。

InvMixColumns 是整段最技巧化的部分。它以 4 字节为一组,先查 _mix 得到四个 32 位值,再按字节错位异或:

t = s0 ^ (s1 >>> 8) ^ (s1 << 24) ^ (s2 >>> 16) ^ (s2 << 16) ^ (s3 >>> 24) ^ (s3 << 8);

每个 _mix[x] 已经包含「字节 x 对四行输出的贡献」,四个值分别对应这一列的四个输入字节。右移与左移是在把这四组贡献对齐到同一组输出字节上:>>> 8、>>> 16、>>> 24 把不同列位置的贡献移到低位,<< 24、<< 16、<< 8 把溢出的部分补回高位。最终的 32 位值按大端拆成 4 个字节写回 state。

这种查表法比逐字节做 GF(2^8) 乘法快得多,代价是 _mix 占 1 KB。对于要在浏览器主线程之外反复解密大量流的阅读器来说,这个交换是值得的。

加密轮函数

_encrypt 与 _decrypt 是镜像结构,但轮顺序相反:

  _encrypt(input, key) {
    const s = this._s;
 
    let t, u, v;
    const state = new Uint8Array(16);
    state.set(input);
 
    for (let j = 0; j < 16; ++j) {
      // AddRoundKey
      state[j] ^= key[j];
    }
 
    for (let i = 1; i < this._cyclesOfRepetition; i++) {
      // SubBytes
      for (let j = 0; j < 16; ++j) {
        state[j] = s[state[j]];
      }
      // ShiftRows
      v = state[1];
      state[1] = state[5];
      state[5] = state[9];
      state[9] = state[13];
      state[13] = v;
      v = state[2];
      u = state[6];
      state[2] = state[10];
      state[6] = state[14];
      state[10] = v;
      state[14] = u;
      v = state[3];
      u = state[7];
      t = state[11];
      state[3] = state[15];
      state[7] = v;
      state[11] = u;
      state[15] = t;
      // MixColumns
      for (let j = 0; j < 16; j += 4) {
        const s0 = state[j];
        const s1 = state[j + 1];
        const s2 = state[j + 2];
        const s3 = state[j + 3];
        t = s0 ^ s1 ^ s2 ^ s3;
        state[j] ^= t ^ this._mixCol[s0 ^ s1];
        state[j + 1] ^= t ^ this._mixCol[s1 ^ s2];
        state[j + 2] ^= t ^ this._mixCol[s2 ^ s3];
        state[j + 3] ^= t ^ this._mixCol[s3 ^ s0];
      }
      // AddRoundKey
      for (let j = 0, k = i * 16; j < 16; ++j, ++k) {
        state[j] ^= key[k];
      }
    }
 
    // SubBytes
    for (let j = 0; j < 16; ++j) {
      state[j] = s[state[j]];
    }
    // ShiftRows
    v = state[1];
    state[1] = state[5];
    state[5] = state[9];
    state[9] = state[13];
    state[13] = v;
    v = state[2];
    u = state[6];
    state[2] = state[10];
    state[6] = state[14];
    state[10] = v;
    state[14] = u;
    v = state[3];
    u = state[7];
    t = state[11];
    state[3] = state[15];
    state[7] = v;
    state[11] = u;
    state[15] = t;
    // AddRoundKey
    for (let j = 0, k = this._keySize; j < 16; ++j, ++k) {
      state[j] ^= key[k];
    }
    return state;
  }

加密分三段:

  1. 初始轮密钥加。明文先与第一轮轮密钥异或,轮密钥下标从 0 开始。
  2. 中间轮。从 1 到 _cyclesOfRepetition - 1,每轮依次做 SubBytes、ShiftRows、MixColumns、AddRoundKey(第 i 轮密钥)。
  3. 末轮。只做 SubBytes、ShiftRows、AddRoundKey(最后一轮密钥,下标 _keySize),没有 MixColumns。

ShiftRows 是 InvShiftRows 的反向:第 1 行左移 1 位,第 2 行左移 2 位,第 3 行左移 3 位。源码同样按行展开:

v = state[1];
state[1] = state[5];
state[5] = state[9];
state[9] = state[13];
state[13] = v;

第 1 行的链是 1 → 5 → 9 → 13,与解密的 13 → 9 → 5 → 1 正好相反。三行的循环长度不同,所以第 1 行只需一个临时变量,第 2 行需要两个,第 3 行需要三个。

MixColumns 没有查 _mix,而是用 _mixCol 的 xtime 做了一个优化形式:

t = s0 ^ s1 ^ s2 ^ s3;
state[j] ^= t ^ this._mixCol[s0 ^ s1];
state[j + 1] ^= t ^ this._mixCol[s1 ^ s2];
state[j + 2] ^= t ^ this._mixCol[s2 ^ s3];
state[j + 3] ^= t ^ this._mixCol[s3 ^ s0];

标准定义里,一列输出是:

  • 第 0 行:2·s0 ^ 3·s1 ^ s2 ^ s3
  • 第 1 行:s0 ^ 2·s1 ^ 3·s2 ^ s3
  • 第 2 行:s0 ^ s1 ^ 2·s2 ^ 3·s3
  • 第 3 行:3·s0 ^ s1 ^ s2 ^ 2·s3

把 3·x 写成 2·x ^ x,再把公共的 s0 ^ s1 ^ s2 ^ s3 提出来,就得到源码里的形式:t 是四字节异或,_mixCol[s0 ^ s1] 是 2·(s0 ^ s1)。因为 GF(2^8) 上的乘法对异或分配,2·s0 ^ 2·s1 等于 2·(s0 ^ s1),所以每行只需要一次查表。

把两段轮函数放在一起对比,可以看清「等价逆密码」的取舍:

阶段加密解密
起始AddRoundKey(key[0..16])AddRoundKey(key[_keySize..])
中间轮序SubBytes、ShiftRows、MixColumns、AddRoundKeyInvShiftRows、InvSubBytes、AddRoundKey、InvMixColumns
末轮SubBytes、ShiftRows、AddRoundKeyInvShiftRows、InvSubBytes、AddRoundKey
密钥顺序从第一轮到最后一轮从最后一轮到第一轮

CBC 解密与填充

块密码只负责 16 字节一块的变换,CBC 模式还要把块链接起来。解密时每块先独立解,再与前一块密文异或:

  _decryptBlock2(data, finalize) {
    const sourceLength = data.length;
    let buffer = this.buffer,
      bufferLength = this.bufferPosition;
    const result = [];
    let iv = this.iv;
 
    for (let i = 0; i < sourceLength; ++i) {
      buffer[bufferLength] = data[i];
      ++bufferLength;
      if (bufferLength < 16) {
        continue;
      }
      // buffer is full, decrypting
      const plain = this._decrypt(buffer, this._key);
      // xor-ing the IV vector to get plain text
      for (let j = 0; j < 16; ++j) {
        plain[j] ^= iv[j];
      }
      iv = buffer;
      result.push(plain);
      buffer = new Uint8Array(16);
      bufferLength = 0;
    }
    // saving incomplete buffer
    this.buffer = buffer;
    this.bufferLength = bufferLength;
    this.iv = iv;
    if (result.length === 0) {
      return new Uint8Array(0);
    }
    // combining plain text blocks into one
    let outputLength = 16 * result.length;
    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);
      }
    }
    const output = new Uint8Array(outputLength);
    for (let i = 0, j = 0, ii = result.length; i < ii; ++i, j += 16) {
      output.set(result[i], j);
    }
    return output;
  }

这段代码有三个层次的逻辑:

第一层是攒块。 输入可能不是 16 的整数倍,所以先把字节塞进 buffer,每满 16 字节才处理一次。不满的部分留在 buffer 里,下次调用继续用。

第二层是链接。 每块解出来之后,与 iv 逐字节异或。第一块的 iv 来自密文头部,后续块的 iv 是上一块密文。源码在解密前把 iv = buffer 保存下来,再把 buffer 换成新的 16 字节数组;这一手很关键:如果原地复用同一个缓冲,下一块解密时会把它覆盖,iv 就指向了被改写的内容。换成新数组之后,iv 仍然指向旧的密文块,链接关系不会被破坏。

第三层是去填充。 PDF 用 PKCS#7(源码注释写 RFC 2898):明文补齐到 16 的整数倍,补几个字节就写几个字节的值。如果明文本来就是 16 的整数倍,额外补一整块 16 个 0x10。解密时读最后一字节 psLen,再检查末尾 psLen 个字节是否都等于 psLen。全部相等才认为填充有效,否则把 psLen 置 0,按「没有填充」处理。

最后这一条容错很重要。加密文档千奇百怪,有些生成器写出的填充不符合规范,或者流长度本身就不对齐。直接抛错会让整页内容读不出来;按无填充处理,至少能把可用的数据交出去。finalize 为真时才检查填充,因为只有最后一块才知道「后面没有数据了」。中间块即使最后一字节恰好是 16,也不能当填充去掉。

result[result.length - 1] = lastBlock.subarray(0, 16 - psLen) 用的是视图而不是复制,随后的 output.set 会把视图里的字节搬进最终缓冲。outputLength 先按整块计算,再减去填充长度,所以最终数组不会多出填充字节。

decryptBlock 的首次调用

decryptBlock 不是直接调用 _decryptBlock2,而是先处理「第一块之前的 IV」:

  decryptBlock(data, finalize, iv = null) {
    const sourceLength = data.length;
    const buffer = this.buffer;
    let bufferLength = this.bufferPosition;
    // If an IV is not supplied, wait for IV values. They are at the start
    // of the stream.
    if (iv) {
      this.iv = iv;
    } else {
      for (
        let i = 0;
        bufferLength < 16 && i < sourceLength;
        ++i, ++bufferLength
      ) {
        buffer[bufferLength] = data[i];
      }
      if (bufferLength < 16) {
        // Need more data.
        this.bufferLength = bufferLength;
        return new Uint8Array(0);
      }
      this.iv = buffer;
      data = data.subarray(16);
    }
    this.buffer = new Uint8Array(16);
    this.bufferLength = 0;
    // starting decryption
    this.decryptBlock = this._decryptBlock2;
    return this.decryptBlock(data, finalize);
  }

这里有两种入口:

  • 显式给 IV:getOwnerKey、getUserKey 与 Algorithm 2.B 都会传一个 IV(通常是 16 个 0 或密钥的一部分)。这时 this.iv = iv,输入数据从第一个字节开始就是密文。
  • 不给 IV:PDF 的流与字符串把 IV 放在密文最前面。首次调用要先把前 16 字节读进缓冲作为 IV;如果这次给的数据不足 16 字节,就返回空数组,等下一次调用继续凑。

凑够之后,this.iv = buffer,data = data.subarray(16) 把 IV 从输入里剥掉,再重置缓冲,最后执行一句很关键的自我替换:

this.decryptBlock = this._decryptBlock2;

这句在实例上新建了一个同名属性,遮蔽了原型上的 decryptBlock。此后的调用都直接走 _decryptBlock2,不再重复「读 IV」的逻辑。新实例仍然从原型拿到带 IV 处理的版本,所以每条流都能正确消费自己的头部。这是用方法替换实现的惰性状态机,比在每次调用里判断 if (!this.iv) 更省一次分支,也让「首次调用」和「后续调用」的职责彻底分开。

这里还有一个读源码时值得留意的细节:_decryptBlock2 与 decryptBlock 都从 this.bufferPosition 取初始缓冲长度,而 bufferPosition 只在构造函数里置 0,之后从未更新。跨调用真正保存残余的是 this.buffer,下次却仍从位置 0 开始写。在现有调用方式下这不会造成问题:DecryptStream 按 512 字节块喂入,512 是 16 的整数倍;decryptString 与 /OE、/UE 都是一次喂完整数据;最后一块即使不整,也已经到 finalize,残余本来就该丢弃。换句话说,这段代码假设「除末块外,每次输入都是块对齐的」,而这个假设由上游保证。

CBC 加密

加密方向的链接顺序相反:先与 IV 异或,再做轮函数;输出密文成为下一块的 IV。

  encrypt(data, iv) {
    const sourceLength = data.length;
    let buffer = this.buffer,
      bufferLength = this.bufferPosition;
    const result = [];
 
    iv ||= new Uint8Array(16);
    for (let i = 0; i < sourceLength; ++i) {
      buffer[bufferLength] = data[i];
      ++bufferLength;
      if (bufferLength < 16) {
        continue;
      }
 
      for (let j = 0; j < 16; ++j) {
        buffer[j] ^= iv[j];
      }
 
      // buffer is full, encrypting
      const cipher = this._encrypt(buffer, this._key);
      iv = cipher;
      result.push(cipher);
      buffer = new Uint8Array(16);
      bufferLength = 0;
    }
    // saving incomplete buffer
    this.buffer = buffer;
    this.bufferLength = bufferLength;
    this.iv = iv;
    if (result.length === 0) {
      return new Uint8Array(0);
    }
    // combining plain text blocks into one
    const outputLength = 16 * result.length;
    const output = new Uint8Array(outputLength);
    for (let i = 0, j = 0, ii = result.length; i < ii; ++i, j += 16) {
      output.set(result[i], j);
    }
    return output;
  }

与解密相比,有三处不同:

  1. 没有 finalize。加密不做去填充,也不在末尾补填充;填充由调用方负责。encryptString 会先把字符串补齐到 16 的倍数,PDF20 的 Algorithm 2.B 则明确要求不带填充。
  2. 没有自我替换。每次调用都接收 iv 参数,缺省时用全零 IV。this.iv 在结尾被保存,但下一次调用不会读取它作为默认值;真正决定链接起点的是参数。
  3. 输出长度只算整块。不满 16 字节的尾巴留在 this.buffer 里,不产生输出。没有 finalize 就意味着这段尾巴永远不会被冲出;调用方必须保证输入是块对齐的,否则最后几个字节会静默丢失。
维度decryptBlockencrypt
IV 来源参数,或密文头部 16 字节参数,缺省全零
首次调用可能是「收集 IV」阶段,返回空数组直接处理,无特殊阶段
链接方向密文块异或明文块明文块异或前一块密文
末块处理finalize 为真时去 PKCS#7 填充不填充,残余留在缓冲
方法替换首次调用后替换为 _decryptBlock2不替换
典型调用方流、字符串、/OE、/UEencryptString、Algorithm 2.B

设计要点:CBC 的正确性依赖「密文块在解密前不能被覆盖」。源码用「每处理完一块就换一个新的 16 字节数组」来保证这一点,而不是在 iv 上做显式复制。代价是每块一次小对象分配,收益是链接逻辑与缓冲逻辑可以共用同一个 buffer 字段。这是典型的以分配换清晰,在解密路径上并不构成瓶颈。

AES128Cipher 与 AES256Cipher:只差密钥扩展

两个子类的构造函数都只有三行有效代码,差异集中在四个常量上:

class AES128Cipher extends AESBaseCipher {
  constructor(key) {
    super();
 
    this._cyclesOfRepetition = 10;
    this._keySize = 160; // bits
 
    this._key = this._expandKey(key);
  }
class AES256Cipher extends AESBaseCipher {
  constructor(key) {
    super();
 
    this._cyclesOfRepetition = 14;
    this._keySize = 224; // bits
 
    this._key = this._expandKey(key);
  }
项目AES128CipherAES256Cipher
输入密钥长度16 字节(128 位)32 字节(256 位)
轮数1014
扩展密钥长度176 字节240 字节
_keySize160224
每轮密钥字数4 字4 字
初始密钥字数4 字8 字
额外变换无每 32 字节一次额外 SubWord

_keySize 是最后一轮轮密钥的字节偏移:AES-128 有 11 组轮密钥(初始加 10 轮),前 10 组占 160 字节,最后一组从 160 开始;AES-256 有 15 组,最后一组从 224 开始。注释里的 bits 是历史遗留,实际按字节下标使用。

AES128Cipher:176 字节轮密钥

AES-128 的密钥扩展从 16 字节初始密钥出发,每次生成 4 字节新字,直到 176 字节。源码用一张 rcon 表提供轮常数:

_rcon = new Uint8Array([
  0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, 0xab, 0x4d, 0x9a,
  0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, 0x6a,
  // 后续项按 AES 的 xtime 序列继续
]);

第 0 项 0x8d 是一个占位值,让轮数下标从 1 开始对齐。真正的轮常数从 0x01 开始,每次乘 2,溢出时异或 0x1b。

  _expandKey(cipherKey) {
    const b = 176;
    const s = this._s;
    const rcon = this._rcon;
 
    const result = new Uint8Array(b);
    result.set(cipherKey);
 
    for (let j = 16, i = 1; j < b; ++i) {
      // RotWord
      let t1 = result[j - 3];
      let t2 = result[j - 2];
      let t3 = result[j - 1];
      let t4 = result[j - 4];
      // SubWord
      t1 = s[t1];
      t2 = s[t2];
      t3 = s[t3];
      t4 = s[t4];
      // Rcon
      t1 ^= rcon[i];
      for (let n = 0; n < 4; ++n) {
        result[j] = t1 ^= result[j - 16];
        j++;
        result[j] = t2 ^= result[j - 16];
        j++;
        result[j] = t3 ^= result[j - 16];
        j++;
        result[j] = t4 ^= result[j - 16];
        j++;
      }
    }
    return result;
  }

逐段看:

  1. result 长 176 字节,前 16 字节直接放入初始密钥。
  2. 从第 16 字节开始,每轮取上一组密钥的最后 4 字节(j - 4 到 j - 1),按 t4、t1、t2、t3 的顺序读入。这里的命名看起来反了,其实是按「32 位字的高字节到低字节」排列:t4 是最高位,t1 是最低位。RotWord 在逻辑上把 [t4, t1, t2, t3] 左移一个字节,得到 [t1, t2, t3, t4]。
  3. 四个字节各自过 S 盒,这就是 SubWord。
  4. 最低位 t1 异或轮常数 rcon[i]。
  5. 内层循环执行四次,每次写一个字节,并与前 16 字节的对应位置异或。t1 在写出后又被赋值为写出的结果,所以同一个字里后面的字节会继续与刚写入的值链式异或。这正是 AES 密钥扩展的递推:新字等于旧字异或「前 16 字节处的字」,逐字推进。

循环变量 j 在内外两层都被修改,i 只在每次外层循环结束时加一。条件 j < b 由内层循环负责推进到 176。写完后,_key 就是 11 组轮密钥。

AES256Cipher:240 字节轮密钥

AES-256 的密钥扩展多了一步:初始密钥 32 字节(8 个字),在第 8 个字之后每生成 4 个字,还要对前一个字做一次 SubWord,不做 RotWord,也不加轮常数。源码用 j % 32 判断:

  _expandKey(cipherKey) {
    const b = 240;
    const s = this._s;
 
    const result = new Uint8Array(b);
    result.set(cipherKey);
 
    let r = 1;
    let t1, t2, t3, t4;
    for (let j = 32, i = 1; j < b; ++i) {
      if (j % 32 === 16) {
        t1 = s[t1];
        t2 = s[t2];
        t3 = s[t3];
        t4 = s[t4];
      } else if (j % 32 === 0) {
        // RotWord
        t1 = result[j - 3];
        t2 = result[j - 2];
        t3 = result[j - 1];
        t4 = result[j - 4];
        // SubWord
        t1 = s[t1];
        t2 = s[t2];
        t3 = s[t3];
        t4 = s[t4];
        // Rcon
        t1 ^= r;
        if ((r <<= 1) >= 256) {
          r = (r ^ 0x1b) & 0xff;
        }
      }
 
      for (let n = 0; n < 4; ++n) {
        result[j] = t1 ^= result[j - 32];
        j++;
        result[j] = t2 ^= result[j - 32];
        j++;
        result[j] = t3 ^= result[j - 32];
        j++;
        result[j] = t4 ^= result[j - 32];
        j++;
      }
    }
    return result;
  }

三个分支对应三种情况:

  • j % 32 === 0:新一轮开始,取上一组密钥的最后 4 字节,做 RotWord、SubWord,再异或轮常数 r,然后 r 乘 2(溢出时异或 0x1b)。
  • j % 32 === 16:初始密钥 8 个字的后半段起点,只做 SubWord,直接使用上一轮循环留下的 t1 到 t4。
  • 其他位置:不改变 t1 到 t4,直接进入内层循环做异或推进。

内层循环的异或距离是 32 字节,而不是 AES-128 的 16 字节,因为 AES-256 的一组密钥是 8 个字、32 字节。t1 到 t4 在每次写出后继续被赋值,所以一个字内部的链式关系与 AES-128 一致。

r 的更新方式是 (r <<= 1) >= 256 时异或 0x1b。这里 r 不是 8 位无符号类型,先左移再判断,最后与 0xff 掩码,等价于对 8 位做 xtime。

两个子类的分工

把两个类放在基类的框架里看,职责非常清楚:

类提供不提供
AESBaseCipher四张表、_encrypt、_decrypt、CBC 缓冲、填充处理、IV 逻辑轮数、轮密钥、密钥长度
AES128Cipher10 轮、176 字节轮密钥、rcon 表轮函数、CBC
AES256Cipher14 轮、240 字节轮密钥、额外的 SubWord 步骤轮函数、CBC

这也解释了为什么 PDF20 可以直接复用 AES128Cipher:Algorithm 2.B 只需要「用 16 字节密钥、指定 IV、无填充地加密一段数据」,AES128Cipher 的 encrypt 正好提供了这个能力。它不需要知道这把密钥来自哪里,也不需要对象号。

设计要点:AES-128 与 AES-256 在密码学上是两种参数化,在代码里是同一个基类加两份密钥扩展。把「轮函数」与「密钥调度」拆开,既避免了重复实现 10 轮与 14 轮的循环,也让 PDF20 能把 AES-128 当成一个纯粹的「CBC 加密原语」来用,而不必关心 PDF 的加密字典。

口令校验:PDFBase、PDF17 与 PDF20

密码算法的三个类只服务一件事:判断「用户给的这串字符」是不是 /O 或 /U 所保护的那个口令,并在判断成功时把文件密钥交出来。它们不参与对象级加解密,也不认识 /CF、/StmF。所以这一段是纯函数式的:输入是口令字节、盐、校验值,输出是布尔值或 32 字节密钥。

PDFBase:两个校验、两个取钥

基类定义了四个公共方法,_hash 留给子类实现:

class PDFBase {
  constructor() {
    if (
      (typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) &&
      this.constructor === PDFBase
    ) {
      unreachable('Cannot initialize PDFBase.');
    }
  }
 
  _hash(password, input, userBytes) {
    unreachable('Abstract method `_hash` called');
  }
 
  checkOwnerPassword(password, ownerValidationSalt, userBytes, ownerPassword) {
    const hashData = new Uint8Array(password.length + 56);
    hashData.set(password, 0);
    hashData.set(ownerValidationSalt, password.length);
    hashData.set(userBytes, password.length + ownerValidationSalt.length);
    const result = this._hash(password, hashData, userBytes);
    return isArrayEqual(result, ownerPassword);
  }
 
  checkUserPassword(password, userValidationSalt, userPassword) {
    const hashData = new Uint8Array(password.length + 8);
    hashData.set(password, 0);
    hashData.set(userValidationSalt, password.length);
    const result = this._hash(password, hashData, []);
    return isArrayEqual(result, userPassword);
  }
 
  getOwnerKey(password, ownerKeySalt, userBytes, ownerEncryption) {
    const hashData = new Uint8Array(password.length + 56);
    hashData.set(password, 0);
    hashData.set(ownerKeySalt, password.length);
    hashData.set(userBytes, password.length + ownerKeySalt.length);
    const key = this._hash(password, hashData, userBytes);
    const cipher = new AES256Cipher(key);
    return cipher.decryptBlock(ownerEncryption, false, new Uint8Array(16));
  }
 
  getUserKey(password, userKeySalt, userEncryption) {
    const hashData = new Uint8Array(password.length + 8);
    hashData.set(password, 0);
    hashData.set(userKeySalt, password.length);
    // `key` is the decryption key for the UE string.
    const key = this._hash(password, hashData, []);
    const cipher = new AES256Cipher(key);
    return cipher.decryptBlock(userEncryption, false, new Uint8Array(16));
  }
}

两个校验方法的差别在两个地方:

方法盐的长度额外输入哈希输入总长比较对象
checkUserPassword8 字节无口令长度加 8/U 的前 32 字节
checkOwnerPassword8 字节/U 的前 48 字节口令长度加 56/O 的前 32 字节

用户校验只把「口令加用户校验盐」交给 _hash。所有者校验则把「口令加所有者校验盐加 /U 的前 48 字节」一起哈希。为什么要带上 /U?因为所有者密码的验证值必须与用户密码绑定:如果有人只改 /U 而不改 /O,所有者校验就会失败。这样两份密码形成一条链,不能各自独立伪造。

isArrayEqual 做的是逐字节严格比较,长度不同直接返回假。这里没有用「先算哈希再比字符串」之类的方式,因为 _hash 返回的就是字节数组,比较的是定长结果。

两个取钥方法的输入结构相同,只是盐换成「密钥盐」,哈希结果直接当作 AES-256 的密钥,去解密 /OE 或 /UE:

const cipher = new AES256Cipher(key);
return cipher.decryptBlock(ownerEncryption, false, new Uint8Array(16));

decryptBlock 的第三个参数是 16 字节全零 IV,第二个参数 false 表示不去填充。/OE 与 /UE 都是 32 字节的密文,正好两个 AES 块,不需要填充,也不应该去掉任何字节。解出来的 32 字节就是文件密钥。

/O 与 /U 在 R 5、R 6 里的布局是固定的:

偏移长度/O 中的含义/U 中的含义
0-3132所有者口令校验值用户口令校验值
32-398所有者校验盐用户校验盐
40-478所有者密钥盐用户密钥盐
48 之后可变通常没有所有者校验时取前 48 字节

这解释了 checkOwnerPassword 为什么需要 userBytes:它拿的是 /U 的前 48 字节,包含用户校验值与两个用户盐,但不包含用户密钥盐之后的任何内容。所有者校验因此不仅验证「你知道所有者密码」,还验证「这份 /U 没有被换掉」。

PDF17:一次 SHA-256

PDF17 对应 R 5,_hash 只有一行:

class PDF17 extends PDFBase {
  _hash(password, input, userBytes) {
    return calculateSHA256(input, 0, input.length);
  }
}

它接收三个参数,但只用了 input。password 与 userBytes 之所以保留在签名里,是因为父类的四个方法都按统一形状调用;PDF17 不需要在哈希循环里重新拼接它们,因为 input 已经是拼好的完整数据。

calculateSHA256 返回 32 字节。校验时与 /O 或 /U 的前 32 字节比较;取钥时这 32 字节直接作为 AES-256 密钥。注意这里没有迭代:R 5 的安全性来自 SHA-256 与盐,而不是反复哈希。R 6 才引入迭代,目的是增加离线暴力破解的成本。

PDF20:Algorithm 2.B

PDF20 对应 R 6,是整个文件里最复杂的算法:

class PDF20 extends PDFBase {
  _hash(password, input, userBytes) {
    // This refers to Algorithm 2.B as defined in ISO 32000-2.
    let k = calculateSHA256(input, 0, input.length).subarray(0, 32);
    let e = [0];
    let i = 0;
    while (i < 64 || e.at(-1) > i - 32) {
      const combinedLength = password.length + k.length + userBytes.length,
        combinedArray = new Uint8Array(combinedLength);
      let writeOffset = 0;
      combinedArray.set(password, writeOffset);
      writeOffset += password.length;
      combinedArray.set(k, writeOffset);
      writeOffset += k.length;
      combinedArray.set(userBytes, writeOffset);
 
      const k1 = new Uint8Array(combinedLength * 64);
      for (let j = 0, pos = 0; j < 64; j++, pos += combinedLength) {
        k1.set(combinedArray, pos);
      }
      // AES128 CBC NO PADDING with first 16 bytes of k as the key
      // and the second 16 as the iv.
      const cipher = new AES128Cipher(k.subarray(0, 16));
      e = cipher.encrypt(k1, k.subarray(16, 32));
      // Now we have to take the first 16 bytes of an unsigned big endian
      // integer and compute the remainder modulo 3. That is a fairly large
      // number and JavaScript isn't going to handle that well.
      // The number is e0 + 256 * e1 + 256^2 * e2... and 256 % 3 === 1, hence
      // the powers of 256 are === 1 modulo 3 and finally the number modulo 3
      // is equal to the remainder modulo 3 of the sum of the e_n.
      const remainder = Math.sumPrecise(e.slice(0, 16)) % 3;
      if (remainder === 0) {
        k = calculateSHA256(e, 0, e.length);
      } else if (remainder === 1) {
        k = calculateSHA384(e, 0, e.length);
      } else if (remainder === 2) {
        k = calculateSHA512(e, 0, e.length);
      }
      i++;
    }
    return k.subarray(0, 32);
  }
}

逐步拆解:

第一步,初始化。 对 input 做一次 SHA-256,取前 32 字节作为 k。input 是父类拼好的「口令加盐」或「口令加盐加 /U 前 48 字节」。e 初始为 [0],只是一个占位,让循环条件里的 e.at(-1) 有值可读。

第二步,循环条件。 while (i < 64 || e.at(-1) > i - 32) 有两层含义:

  • 前 64 轮无条件执行。i 从 0 开始,所以至少跑 64 次。
  • 64 轮之后,看上一轮输出 e 的最后一个字节。只要它大于 i - 32,就继续。这个条件来自规范,作用是把迭代次数变成一个由中间结果决定的变量:大多数文档会在 64 到 70 轮左右停下,偶尔会更长。它既不是固定轮数,也不是无限循环。

第三步,拼接待加密数据。 combinedArray 等于「口令字节」「当前 k」「userBytes」三段拼接。userBytes 在用户校验时是空数组,在所有者校验时是 /U 的前 48 字节。然后把这个数组重复 64 次,得到 k1,长度是 combinedLength * 64。重复 64 次是规范要求,用来把输入拉长到远超一个 AES 块。

第四步,AES-128-CBC 加密。 密钥是 k 的前 16 字节,IV 是 k 的后 16 字节(k 一共 32 字节)。encrypt 不填充,输出只包含完整块。这里复用的正是前面讲的 AES128Cipher,没有另写一份 AES。

第五步,用结果决定下一轮哈希。 把 e 的前 16 字节当成一个大端无符号整数,对 3 取余。这个数可能有 128 位,JavaScript 的 Number 存不下。源码用了一个数论技巧:

  • 这个大整数等于 e0 + 256·e1 + 256^2·e2 + ...。
  • 因为 256 % 3 === 1,所以 256 的任意次幂对 3 都余 1。
  • 于是整个数对 3 的余数,等于所有字节之和对 3 的余数。

Math.sumPrecise(e.slice(0, 16)) 求和,再 % 3。Math.sumPrecise 是较新的精确求和 API,用来避免普通浮点累加在极端情况下的舍入误差。余数为 0 用 SHA-256,为 1 用 SHA-384,为 2 用 SHA-512。三条路径都会产生至少 32 字节,下一轮再取前 32 字节作为新的 k。

第六步,收尾。 循环结束后返回 k 的前 32 字节。这个值就是校验值,也是派生 /OE、/UE 解密密钥的输入。

PDF20 与 PDF17 的差异可以列成表:

项目PDF17(R 5)PDF20(R 6)
初始哈希SHA-256SHA-256,取前 32 字节
迭代无至少 64 轮,末字节条件决定是否继续
每轮加密无AES-128-CBC,密钥与 IV 都取自当前 k
每轮哈希选择固定 SHA-256由前 16 字节之和对 3 取余决定 SHA-256/384/512
输出32 字节前 32 字节
典型耗时一次哈希数十轮 AES 加哈希,明显更慢

这个「慢」是设计目标,不是实现缺陷。R 6 的迭代让每次猜测口令都要付出数十轮 AES 与哈希的代价,离线爆破的成本被拉高。

R5 与 R6 的口令编码

口令不是直接拿 JavaScript 字符串去哈希的。不同的修订对「口令怎么变成字节」有不同规定,工厂里对应三条分支:

修订预处理字节化方式源码位置
R 2、R 3、R 4无stringToBytes,逐字符取低 8 位构造函数里的 else 分支
R 5无utf8PasswordToBytesalgorithm === 5 分支
R 6saslPrep,再做 UTF-8utf8PasswordToBytesrevision === 6 分支

R 2 到 R 4 是较老的修订,规范把口令当作字节串处理。PDF.js 用 stringToBytes,对每个字符取 charCodeAt(i) & 0xff,也就是按低 8 位截断。这对 ASCII 口令没问题,对非 ASCII 口令则依赖生成器当初写入时的编码约定。

R 5 与 R 6 改用 UTF-8。R 6 还多一步 SASLprep:按 RFC 4013 做 Unicode 规范化、空白折叠与禁用字符检查,把「看起来一样但码点不同」的口令归一化。它由 sasl_prep.js 提供,不在 crypto.js 里实现。

设计要点:口令校验不是「解密 /O 再比对」,而是「用候选口令重新计算校验值,再与 /O、/U 比较」。校验值是不可逆的哈希,所以文档里没有可还原的口令;同时在 R 5、R 6 里,校验值与密钥盐分离,用户可以改口令而不必重新加密整份文档的内容——只需要重新包装 /OE、/UE。

utf8PasswordToBytes:口令的字节化

utf8PasswordToBytes 只有八行,却是 R 5、R 6 的口令入口:

function utf8PasswordToBytes(password) {
  try {
    password = utf8StringToString(password);
  } catch {
    warn('CipherTransformFactory: Unable to convert UTF8 encoded password.');
  }
  return stringToBytes(password);
}

utf8StringToString 来自 shared/util.js,实现是:

function utf8StringToString(str) {
  return unescape(encodeURIComponent(str));
}

encodeURIComponent 把 JavaScript 字符串里的非 ASCII 字符转成百分号编码的 UTF-8 序列,unescape 再把百分号编码还原成「每个字节一个字符」的二进制字符串。两者合起来,效果是把一个 UTF-16 字符串变成「UTF-8 字节串」,每个字符的码点都在 0 到 255 之间。最后 stringToBytes 逐字符取低 8 位,得到真正的 Uint8Array。

为什么要包一层 try?encodeURIComponent 遇到孤立代理项(lone surrogate)会抛 URIError。用户输入里出现半个代理对并不罕见,比如某些输入法或复制粘贴留下的残片。这时不能直接让整个文档打不开,源码的处理是打印警告,然后按原字符串走 stringToBytes,保留一次「尽力而为」的尝试。

工厂里对 R 6 还多做了一层兜底:如果 SASLprep 之后的字符串与原字符串不同,除了预处理版本,还会把原字符串再转一次字节,作为第二个候选。这样既遵守规范,又能兼容那些生成器没有做 SASLprep 的文档。这个逻辑在构造函数里:

if (revision === 6) {
  const preppedPassword = saslPrep(password);
  passwordBytes = utf8PasswordToBytes(preppedPassword);
  if (preppedPassword !== password) {
    rawPasswordBytes = utf8PasswordToBytes(password);
  }
}

rawPasswordBytes 只有在「预处理确实改变了字符串」时才存在。校验时会按顺序尝试两个候选,第一个成功就停止。这是典型的兼容性优先:规范路径先走,失败再退回原始输入。

设计要点:口令的「字符」与「字节」在 PDF 里不是一回事。R 2 到 R 4 把口令当字节串,R 5、R 6 把它当 UTF-8。utf8PasswordToBytes 是这条边界的唯一转换点,所有编码差异都被收在这里,工厂后面的代码只面对字节数组。

CipherTransform:按对象分派的变换门面

工厂算出文件密钥之后,真正的加解密入口是 CipherTransform。它不持有密钥,只持有一个「解析器」——给定密码过滤器名,返回一个密码构造器:

class CipherTransform {
  /** @type {Map<string, CipherConstructors>} */
  #cipherCache = new Map();
 
  /** @type {Name | null} */
  embeddedFilterName = null;
 
  constructor(resolveCipher, stringFilterName = null, streamFilterName = null) {
    this.resolveCipher = resolveCipher;
    this.streamFilterName = streamFilterName;
    this.stringFilterName = stringFilterName;
  }
 
  #getCipher(filterName = null) {
    const key = filterName instanceof Name ? filterName.name : "__default__";
 
    return this.#cipherCache.getOrInsertComputed(key, () =>
      this.resolveCipher(filterName)
    );
  }

resolveCipher 是工厂在 createCipherTransform 里现造的闭包。它捕获了 this(工厂)、对象号 num 与 generation gen,所以「用哪把对象密钥」这件事在变换对象里完全不可见。CipherTransform 只负责三件事:缓存过滤器到构造器的映射、按流/字符串选择默认过滤器、保证每次加解密都新建密码实例。

#getCipher 的缓存键有一个特殊值 __default__。当 filterName 不是 Name 时(通常是 null),用的是这个键,而不是空字符串。这样默认过滤器与一个恰好叫空名的过滤器不会撞键。getOrInsertComputed 是较新的 Map 方法:键不存在时才执行回调并写入,语义上等价于「查不到就建」。

缓存的是构造器,不是实例。 createStream、decryptString、encryptString 每次都会 new 一个。构造器本身无状态,实例才有状态;缓存构造器既省掉了重复的过滤器解析,又不会让两条流共用同一张 RC4 置换表或同一个 AES IV。

createStream:流式解密

  createStream(stream, length, cryptFilterName = null) {
    const defaultFilterName =
      this.embeddedFilterName && isDict(stream.dict, "EmbeddedFile")
        ? this.embeddedFilterName
        : this.streamFilterName;
    const Cipher = this.#getCipher(cryptFilterName || defaultFilterName);
    const cipher = new Cipher();
    return new DecryptStream(
      stream,
      length,
      function cipherTransformDecryptStream(data, finalize) {
        return cipher.decryptBlock(data, finalize);
      }
    );
  }

默认过滤器的选择有优先顺序:

  1. 调用方显式传了 cryptFilterName(例如 /Crypt 过滤器 /DecodeParms 里的 /Name),直接用。
  2. 否则,如果这是内嵌文件流且 /EFF 存在,用 /EFF。
  3. 否则用 /StmF。

isDict(stream.dict, "EmbeddedFile") 判断的是流字典的 /Type。只有内嵌文件才允许走 /EFF,因为 /EFF 本来就是为附件单独设置的过滤器。普通内容流即使 embeddedFilterName 存在,也不能误用。

返回的 DecryptStream 拿到的是一个闭包,闭包捕获了刚建的 cipher。DecryptStream 每次读块都调用 decryptBlock(data, finalize);密码实例的状态随着块推进,但闭包本身不关心推进到哪。流的生命周期与密码实例的生命周期在这里被绑定成一对一。

decryptString:整段解密

  decryptString(s) {
    const Cipher = this.#getCipher(this.stringFilterName);
    const cipher = new Cipher();
    let data = stringToBytes(s);
    data = cipher.decryptBlock(data, true);
    return bytesToString(data);
  }

字符串没有「流式读取」的必要:它已经在内存里,长度通常很小。所以这里一次性把整个字符串转成字节,调用 decryptBlock,finalize 直接传 true。对 AES 来说,这意味着一次性处理所有块并去掉末尾填充;对 RC4 来说,true 被忽略。最后 bytesToString 把字节数组转回 PDF 的字节字符串。

注意 s 是字符串而不是 Uint8Array。PDF 的字符串对象在解析器里一直以「每个字符一个字节」的形式保存,所以 stringToBytes 是安全的;如果字符串里出现大于 255 的码点,说明上游已经坏了。

encryptString:写回时的补齐与 IV

  encryptString(s) {
    const Cipher = this.#getCipher(this.stringFilterName);
    const cipher = new Cipher();
    if (cipher instanceof AESBaseCipher) {
      // Append some chars equal to "16 - (M mod 16)"
      // where M is the string length (see section 7.6.2 in PDF specification)
      // to have a final string where the length is a multiple of 16.
      // Special note:
      //   "Note that the pad is present when M is evenly divisible by 16;
      //   it contains 16 bytes of 0x10."
      const strLen = s.length;
      const pad = 16 - (strLen % 16);
      s += String.fromCharCode(pad).repeat(pad);
 
      // Generate an initialization vector
      const iv = new Uint8Array(16);
      crypto.getRandomValues(iv);
 
      let data = stringToBytes(s);
      data = cipher.encrypt(data, iv);
 
      const buf = new Uint8Array(16 + data.length);
      buf.set(iv);
      buf.set(data, 16);
 
      return bytesToString(buf);
    }
 
    let data = stringToBytes(s);
    data = cipher.encrypt(data);
    return bytesToString(data);
  }

AES 分支做三件事:

  1. 补齐。 pad = 16 - (strLen % 16),补 pad 个值为 pad 的字节。当长度正好是 16 的倍数时,pad 等于 16,补一整块 0x10,与规范一致。这里的 strLen 是 JavaScript 字符串长度,也就是字节数,因为 PDF 字符串每个字符只占一个字节。
  2. 生成 IV。 crypto.getRandomValues(iv) 用浏览器的密码学随机源生成 16 字节。每次加密都用新 IV,所以同一个字符串加密两次会得到不同密文;这是 CBC 模式的语义要求,也避免了「相同明文产生相同密文」的泄漏。
  3. 前缀 IV。 密文前面拼上 16 字节 IV,再整体转成字符串。读取方 decryptBlock 首次调用时正好把这个前缀当作 IV 消费掉,读写两侧的格式在这里闭环。

RC4 分支没有补齐、没有 IV、没有前缀:cipher.encrypt(data) 直接异或,长度与输入相同。这也解释了为什么同一个 encryptString 要按 instanceof AESBaseCipher 分支:两种密码对「字符串在线路上长什么样」的约定不同。

操作入口密码实例过滤器来源末块/填充
读流createStream每条流新建显式名、/EFF、/StmFDecryptStream 传 finalize
读字符串decryptString每次新建/StrF固定 true
写字符串encryptString每次新建/StrFAES 补齐并前置随机 IV
写流(字节)encryptString每次新建/StrF与字符串相同

设计要点:CipherTransform 把「选择算法」与「使用算法」彻底分开。它不判断 /V、不派生密钥、不认识对象号;这些都在工厂的闭包里完成。变换对象因此可以被缓存、被多个字符串复用,而真正的密码状态始终是一次性的。

CipherTransformFactory:把字典变成密钥

工厂是文件里最大的类,构造函数接近两百行,私有方法四个。它的工作可以概括成四步:校验字典、拆出校验值与盐、用口令派生文件密钥、把「按对象号选算法」封装成闭包。

三道门槛:Filter、V 与 Length

构造函数开头是一串「快速失败」:

  constructor(dict, fileId, password) {
    const filter = dict.get("Filter");
    if (!isName(filter, "Standard")) {
      throw new FormatError("unknown encryption method");
    }
    this.filterName = filter.name;
    this.dict = dict;
    this.#fileId = fileId;
    const algorithm = dict.get("V");
    if (
      !Number.isInteger(algorithm) ||
      (algorithm !== 1 && algorithm !== 2 && algorithm !== 4 && algorithm !== 5)
    ) {
      throw new FormatError("unsupported encryption algorithm");
    }
    this.algorithm = algorithm;

三道门槛分别对应三种失败:

检查失败条件抛出的错误
/Filter不是 Name,或名字不是 StandardFormatError: unknown encryption method
/V不是整数,或不在 1、2、4、5 之内FormatError: unsupported encryption algorithm
/Length不是整数,或小于 40,或不是 8 的倍数FormatError: invalid key length

/Filter 只支持 Standard,是因为这个文件实现的就是标准安全处理器;其它处理器(例如某些第三方插件)需要完全不同的算法,PDF.js 不在这里假装支持。/V 3 被排除在外,前面已经提过。

恢复 Length

/Length 是较容易缺失或写错的键。源码在缺失时尝试恢复:

let keyLength = dict.get('Length');
if (!keyLength) {
  // Spec asks to rely on encryption dictionary's Length entry, however
  // some PDFs don't have it. Trying to recover.
  if (algorithm <= 3) {
    // For 1 and 2 it's fixed to 40-bit, for 3 40-bit is a minimal value.
    keyLength = 40;
  } else {
    // Trying to find default handler -- it usually has Length.
    const cfDict = dict.get('CF');
    const streamCryptoName = dict.get('StmF');
    if (cfDict instanceof Dict && streamCryptoName instanceof Name) {
      cfDict.suppressEncryption = true; // See comment below.
      const handlerDict = cfDict.get(streamCryptoName.name);
      keyLength = handlerDict?.get('Length') || 128;
      if (keyLength < 40) {
        // Sometimes it's incorrect value of bits, generators specify
        // bytes.
        keyLength <<= 3;
      }
    }
  }
}
if (!Number.isInteger(keyLength) || keyLength < 40 || keyLength % 8 !== 0) {
  throw new FormatError('invalid key length');
}

三条恢复规则:

  • V 1、V 2 固定 40 位;V 3 的最小值也是 40 位,所以统一回退到 40。
  • V 4、V 5 从 /CF 里找 /StmF 指向的处理器的 Length,找不到就用 128。
  • 如果找到的值小于 40,说明生成器把「字节」写进了「位」的字段,左移 3 位转成位。

这里又出现了一次 suppressEncryption = true。原因与 trailer 的 /Encrypt 相同:/CF 可能是间接对象,读它时不能触发解密。handlerDict 也可能是一个引用,取值过程中会走 XRef.fetch;没有这个标志就会递归。

密码过滤器与三个默认名

V 4 之后,流与字符串可以各自选择密码过滤器。源码给三个名字设了默认值:

let cf = null;
let stmf = Name.get('Identity');
let strf = Name.get('Identity');
let eff = stmf;
 
if (algorithm >= 4) {
  cf = dict.get('CF');
  if (cf instanceof Dict) {
    // The 'CF' dictionary itself should not be encrypted, and by setting
    // `suppressEncryption` we can prevent an infinite loop inside of
    // `XRef_fetchUncompressed` if the dictionary contains indirect
    // objects (fixes issue7665.pdf).
    cf.suppressEncryption = true;
  }
  stmf = dict.get('StmF') || Name.get('Identity');
  strf = dict.get('StrF') || Name.get('Identity');
  eff = dict.get('EFF') || stmf;
}
this.cf = cf;
this.stmf = stmf;
this.strf = strf;
this.eff = eff;

默认值是 Identity,不是 None。在 PDF 的密码过滤器语义里,Identity 表示「这一段不加密」;resolveCipher 找不到对应的 CFM 时,也会返回 NullCipher。两者最终效果一致,但来源不同:一个是名字层面的约定,一个是字典缺项时的兜底。

/EFF 缺省等于 /StmF,而不是 Identity。这意味着如果文档没有单独声明内嵌文件的过滤器,附件流就跟随普通流一起加密。只有显式写了 /EFF Identity,附件才会不加密。

从 O、U 拆校验值与盐

const ownerBytes = stringToBytes(dict.get('O')),
  userBytes = stringToBytes(dict.get('U'));
// prepare keys
const ownerPassword = ownerBytes.subarray(0, 32);
const userPassword = userBytes.subarray(0, 32);
const flags = dict.get('P');
const revision = dict.get('R');
// meaningful when V is 4 or 5
const encryptMetadata =
  (algorithm === 4 || algorithm === 5) && dict.get('EncryptMetadata') !== false;
this.encryptMetadata = encryptMetadata;
 
const fileIdBytes = stringToBytes(fileId);

ownerPassword 与 userPassword 是前 32 字节的视图,不是复制。后面的 subarray(32, 40)、subarray(40, 48) 也是视图,所以整个构造函数在 R 5、R 6 路径上不需要重新分配 /O、/U。这种写法依赖 Uint8Array 的 subarray 语义:共享底层缓冲,只改视图范围。

revision 没有做整数校验。dict.get("R") 可能返回 undefined,后面的比较会走 else 分支,最终在 #prepareKeyData 里按 R 2 处理。这是「尽量打开」的策略:缺少 /R 的文档不多,但一旦遇到,严格拒绝不如按最老的修订尝试。

encryptMetadata 只在 V 4、V 5 有意义。它的默认值是真,只有明确写 false 才关闭。这个布尔值会进入哈希输入:R 4 之后,如果元数据不加密,哈希里要补四个 0xff。它还会影响 Catalog 读取元数据流时是否抑制解密。

口令预处理与两条派生路径

构造函数最后一段按 algorithm 分叉:

let passwordBytes, rawPasswordBytes;
if (password) {
  if (revision === 6) {
    const preppedPassword = saslPrep(password);
    passwordBytes = utf8PasswordToBytes(preppedPassword);
    if (preppedPassword !== password) {
      rawPasswordBytes = utf8PasswordToBytes(password);
    }
  } else if (algorithm === 5) {
    passwordBytes = utf8PasswordToBytes(password);
  } else {
    passwordBytes = stringToBytes(password);
  }
}

三条分支对应三套口令编码,前面已经列表说明。这里再强调一个顺序:先判断 revision === 6,再判断 algorithm === 5。这是因为 R 6 必然属于 V 5,如果先判 V 5,R 6 就会漏掉 SASLprep。这种「特殊在前、一般在后」的分支顺序在兼容代码里很常见。

随后:

let encryptionKey;
if (algorithm !== 5) {
  encryptionKey = this.#prepareKeyData(
    fileIdBytes,
    passwordBytes,
    ownerPassword,
    userPassword,
    flags,
    revision,
    keyLength,
    encryptMetadata,
  );
} else {
  const ownerValidationSalt = ownerBytes.subarray(32, 40);
  const ownerKeySalt = ownerBytes.subarray(40, 48);
  const uBytes = userBytes.subarray(0, 48);
  const userValidationSalt = userBytes.subarray(32, 40);
  const userKeySalt = userBytes.subarray(40, 48);
  const ownerEncryption = stringToBytes(dict.get('OE'));
  const userEncryption = stringToBytes(dict.get('UE'));
  const perms = stringToBytes(dict.get('Perms'));
  for (const candidate of rawPasswordBytes ? [passwordBytes, rawPasswordBytes] : [passwordBytes]) {
    encryptionKey = this.#createEncryptionKey20(
      revision,
      candidate,
      ownerPassword,
      ownerValidationSalt,
      ownerKeySalt,
      uBytes,
      userPassword,
      userValidationSalt,
      userKeySalt,
      ownerEncryption,
      userEncryption,
      perms,
    );
    if (encryptionKey) {
      break;
    }
  }
}

V 1、V 2、V 4 走 #prepareKeyData。它们的共同点是「用口令、/O、/U、/P、文件标识做哈希,派生出文件密钥,再用派生结果验证 /U」。V 5 走另一条完全不同的路:不再从 /O、/U 直接派生文件密钥,而是先用盐与口令算出「用户密钥」或「所有者密钥」,再用它去解密 /UE 或 /OE。

V 5 路径里所有盐都是 /O、/U 的切片,/OE、/UE、/Perms 才是独立字符串。perms 被传进 #createEncryptionKey20,但那一版方法签名接收后并未使用——权限副本的校验在这个版本里没有实现。读源码时不用找它的使用点。

#prepareKeyData:R2 到 R4 的密钥派生

这条路径先把「口令、/O、/P、文件标识」拼成一段哈希输入:

const hashDataSize = 40 + ownerPassword.length + fileId.length;
const hashData = new Uint8Array(hashDataSize);
let i = 0,
  j,
  n;
if (password) {
  n = Math.min(32, password.length);
  for (; i < n; ++i) {
    hashData[i] = password[i];
  }
}
j = 0;
while (i < 32) {
  hashData[i++] = CipherTransformFactory._defaultPasswordBytes[j++];
}
// as now the padded password in the hashData[0..i]
hashData.set(ownerPassword, i);
i += ownerPassword.length;
hashData[i++] = flags & 0xff;
hashData[i++] = (flags >> 8) & 0xff;
hashData[i++] = (flags >> 16) & 0xff;
hashData[i++] = (flags >>> 24) & 0xff;
hashData.set(fileId, i);
i += fileId.length;
if (revision >= 4 && !encryptMetadata) {
  hashData.fill(0xff, i, i + 4);
  i += 4;
}
let hash = calculateMD5(hashData, 0, i);
const keyLengthInBytes = keyLength >> 3;
if (revision >= 3) {
  for (j = 0; j < 50; ++j) {
    hash = calculateMD5(hash, 0, keyLengthInBytes);
  }
}
const encryptionKey = hash.subarray(0, keyLengthInBytes);

哈希输入的分段是固定的:

顺序内容长度说明
1口令字节最多 32 字节不足 32 字节用默认填充补满
2/O 的前 32 字节32 字节所有者校验值
3/P4 字节,小端权限位
4文件标识与 /ID[0] 等长文件级盐
5四个 0xff4 字节,可选R 4 之后且元数据不加密时追加

口令不足 32 字节时补的是 _defaultPasswordBytes,那是一段规范规定的 32 字节常量。补满之后再接 /O,所以前 64 字节永远是「32 字节口令填充加 32 字节所有者值」。这也是为什么 hashDataSize 写成 40 + ownerPassword.length + fileId.length:40 等于 32 字节口令区加 4 字节权限区,再加上 4 字节的元数据标志余量。

R 2 只做一次 MD5,R 3 之后把 MD5 结果再hash五十次,每次只取前 keyLengthInBytes 字节。最后截取前 keyLengthInBytes 字节作为文件密钥。这里的迭代不是为了「更安全地存口令」,而是让暴力破解每一次猜测都更贵。

密钥算出之后还要验证。R 3 之后的校验值是:

  1. 用默认填充与文件标识拼一段数据,做 MD5。
  2. 用文件密钥做 RC4,加密这段 MD5。
  3. 再用 key[i] ^ j(j 从 1 到 19)派生出的 19 把密钥,依次加密上一次结果。
  4. 把最终结果与 /U 的前 32 字节逐字节比较。

R 2 则是用文件密钥直接 RC4 加密默认填充,再与 /U 比较。比较成功返回文件密钥,失败返回 null。这段逻辑没有单独的「比较」方法,checkData.every((data, k) => userPassword[k] === data) 就是全部。

#decodeUserPassword:把所有者密码当用户密码

第一次派生失败后,工厂会假设用户输的是所有者密码,先用它解出用户密码:

let cipher, userPassword;
if (revision >= 3) {
  userPassword = ownerPassword;
  const derivedKey = new Uint8Array(keyLengthInBytes);
  for (j = 19; j >= 0; j--) {
    for (let k = 0; k < keyLengthInBytes; ++k) {
      derivedKey[k] = hash[k] ^ j;
    }
    cipher = new ARCFourCipher(derivedKey);
    userPassword = cipher.encryptBlock(userPassword);
  }
} else {
  cipher = new ARCFourCipher(hash.subarray(0, keyLengthInBytes));
  userPassword = cipher.encryptBlock(ownerPassword);
}
return userPassword;

这段代码之前,方法先把口令补满 32 字节做一次 MD5;R 3 之后再把结果迭代五十次。R 3 之后的解密顺序与 #prepareKeyData 的校验过程正好相反:校验是 j 从 1 到 19,解密是 j 从 19 到 0。因为 RC4 的异或自逆,倒着用同样的派生密钥就能还原出被加密的 32 字节用户密码。R 2 只有一轮,直接用哈希前几个字节做密钥。

这里的五十次迭代每次对完整的 16 字节做 MD5;而 #prepareKeyData 里的五十次只取前 keyLengthInBytes 字节。两个算法对「要保留多少位」的规定不同,源码没有合并这两段,而是各写一份。读的时候不要以为其中一处是笔误。

#createEncryptionKey20:R5 与 R6 的校验

这条路径不再「先派生文件密钥再验证」,而是「先验证口令,再用验证结果解包文件密钥」:

const pdfAlgorithm = revision === 6 ? new PDF20() : new PDF17();
 
if (pdfAlgorithm.checkUserPassword(password, userValidationSalt, userPassword)) {
  return pdfAlgorithm.getUserKey(password, userKeySalt, userEncryption);
} else if (
  password.length &&
  pdfAlgorithm.checkOwnerPassword(password, ownerValidationSalt, uBytes, ownerPassword)
) {
  return pdfAlgorithm.getOwnerKey(password, ownerKeySalt, uBytes, ownerEncryption);
}
 
return null;

方法开头还有一段截断:口令最多保留 127 字节,超出部分直接丢弃;空口令归一成空数组,避免后面的 .length 抛错。127 是规范对 UTF-8 口令的长度上限。

校验顺序是先用户、后所有者。用户密码成功就直接解 /UE;否则在口令非空时尝试所有者密码,解 /OE。两个都失败返回 null,由构造函数决定抛「缺密码」还是「密码错」。revision === 6 用 PDF20,其余(也就是 R 5)用 PDF17。

perms 参数虽然一路传到这里,却没有参与任何判断。它对应的 /Perms 是权限的加密副本,本版没有校验;权限位 /P 的作用仍然停留在「声明」层面。

失败路径与延迟提示

文件密钥为 null 时,构造函数分三种情况处理:

    if (!encryptionKey) {
      if (!password) {
        if (
          this.algorithm >= 4 &&
          isName(this.stmf, "Identity") &&
          isName(this.strf, "Identity")
        ) {
          const effCF = this.cf?.get(this.eff.name);
          const authEvent = effCF?.get("AuthEvent");
 
          if (isName(authEvent, "EFOpen")) {
            // For EFOpen with Identity as default stream/string filters, defer
            // password prompting until an EmbeddedFile stream is actually read.
            this.encryptionKey = null;
            return;
          }
        }
        throw new PasswordException(
          "No password given",
          PasswordResponses.NEED_PASSWORD
        );
      }

如果没给口令,且流与字符串的默认过滤器都是 Identity,同时 /EFF 处理器的 /AuthEvent 是 EFOpen,那么文档的正文其实没有加密,只有内嵌文件在打开时需要口令。这时构造函数不抛错,而是把 encryptionKey 设为 null 并直接返回。等到真的读取附件流,resolveCipher 发现没有密钥,才抛 PasswordException。这样用户可以先正常阅读页面,不必为了一个附件在打开文档时就输入密码。

其余情况:

  • 没给口令、又不满足延迟条件,抛 NEED_PASSWORD。
  • 给了口令但两次派生都失败,用 #decodeUserPassword 把口令当所有者密码再试一次 #prepareKeyData;仍失败则抛 INCORRECT_PASSWORD。
  • V 4 且密钥不足 16 字节时,补零扩展到 16 字节。源码注释说这是 undocumented 的兼容处理,用来修两份问题样本。

setPassword 则是给这条链路留的后门。它不修改原工厂的任何字段,而是用同一个字典与文件标识重新构造一个工厂,再把新算出的 encryptionKey 取过来。PDFManager.updatePassword 调用它,所以用户在 UI 里补输密码时,不需要重新解析整个文档,只需要重建这一份密钥。

#buildObjectKey:每个对象一把钥匙

V 1、V 2、V 4 的对象密钥都在这里派生:

  #buildObjectKey(num, gen, encryptionKey, isAes = false) {
    const n = encryptionKey.length;
    const key = new Uint8Array(n + 9);
    key.set(encryptionKey);
    let i = n;
    key[i++] = num & 0xff;
    key[i++] = (num >> 8) & 0xff;
    key[i++] = (num >> 16) & 0xff;
    key[i++] = gen & 0xff;
    key[i++] = (gen >> 8) & 0xff;
    if (isAes) {
      key[i++] = 0x73;
      key[i++] = 0x41;
      key[i++] = 0x6c;
      key[i++] = 0x54;
    }
    const hash = calculateMD5(key, 0, i);
    return hash.subarray(0, Math.min(n + 5, 16));
  }

输入是「文件密钥」「对象号 3 字节小端」「generation 2 字节小端」,AES 还要再加四个字节 sAlT(0x73 0x41 0x6c 0x54)。拼完做 MD5,取前 min(n + 5, 16) 字节作为对象密钥。

三个数字的来源:

  • n + 5:文件密钥长度加上对象号 3 字节与 generation 2 字节。RC4 需要与输入等长的密钥,所以保留全部。
  • 上限 16:AES 的密钥最多 16 字节(AES-128)。MD5 输出本来只有 16 字节,所以对 V 4 而言 n 通常是 16,min(21, 16) 就是 16。
  • sAlT:AES 与 RC4 用同一套派生会得到相同密钥,加一个固定后缀把两条派生路径分开。这是规范要求的域分离。

isAes 只影响这四个后缀字节。同一个对象号、同一份文件密钥,RC4 与 AES 派生出的密钥不同,避免在混合使用两种过滤器的文档里出现密钥复用。

createCipherTransform:把对象号变成算法

工厂的最后一个公开方法负责「按过滤器选密码」:

  createCipherTransform(num, gen) {
    if (this.algorithm === 4 || this.algorithm === 5) {
      /** @type {ResolveCipher} */
      const resolveCipher = filterName => {
        if (!(filterName instanceof Name)) {
          throw new FormatError("Invalid crypt filter name.");
        }
        const cryptFilter = this.cf.get(filterName.name);
        const cfm = cryptFilter?.get("CFM");
 
        if (!cfm || cfm.name === "None") {
          return NullCipher;
        }
        if (!this.encryptionKey) {
          throw new PasswordException(
            "No password given",
            PasswordResponses.NEED_PASSWORD
          );
        }
        if (this.algorithm === 5 || cfm.name === "AESV3") {
          // V=5 always uses 256-bit AES with the file encryption key, even
          // when a producer wrongly sets the crypt filter's CFM to AESV2
          // (bug 2046659).
          return AES256Cipher.bind(null, this.encryptionKey);
        }
        if (cfm.name === "V2") {
          return ARCFourCipher.bind(
            null,
            this.#buildObjectKey(
              num,
              gen,
              this.encryptionKey,
              /* isAes = */ false
            )
          );
        }
        if (cfm.name === "AESV2") {
          return AES128Cipher.bind(
            null,
            this.#buildObjectKey(
              num,
              gen,
              this.encryptionKey,
              /* isAes = */ true
            )
          );
        }
        throw new FormatError("Unknown crypto method");
      };
 
      const transform = new CipherTransform(
        resolveCipher,
        this.strf,
        this.stmf
      );
      transform.embeddedFilterName = this.eff;
      return transform;
    }
 
    // algorithms 1 and 2
    /** @type {ResolveCipher} */
    const resolveCipher = () =>
      ARCFourCipher.bind(
        null,
        this.#buildObjectKey(num, gen, this.encryptionKey, /* isAes = */ false)
      );
    return new CipherTransform(resolveCipher);
  }

resolveCipher 的判定顺序是:

条件返回的构造器密钥来源
CFM 缺失,或 CFM 为 NoneNullCipher无
V 5,或 CFM 为 AESV3AES256Cipher 绑定文件密钥文件密钥,32 字节
CFM 为 V2ARCFourCipher 绑定对象密钥#buildObjectKey
CFM 为 AESV2AES128Cipher 绑定对象密钥#buildObjectKey
其它抛 FormatError: Unknown crypto method无

几个容易看漏的点:

  1. NullCipher 在密钥检查之前。 即使文档没有口令(encryptionKey 为 null),只要某个过滤器写的是 None,这一段仍然可以解密——因为本来就不需要密钥。这正是延迟提示能工作的前提:正文用 Identity,附件用 EFOpen,读正文时不碰密钥。
  2. V 5 优先于 CFM。 规范要求 V 5 一律用 AES-256,但有些生成器把 CFM 写成 AESV2。源码先判 algorithm === 5,直接返回 AES256Cipher,避免被错误的 CFM 带到 128 位分支(注释里的 bug 2046659)。
  3. AESV3 也用文件密钥。 它不调用 #buildObjectKey,因为 32 字节文件密钥已经足够强,规范不再要求对象级派生。
  4. V 1、V 2 走末尾分支。 这时没有 /CF,resolveCipher 忽略过滤器名,总是返回绑定对象密钥的 RC4。CipherTransform 的两个默认过滤器都是 null,#getCipher(null) 会用 __default__ 键命中的同一个解析器。

bind(null, key) 返回的是一个绑定了密钥的构造器。new Cipher() 时,key 已经在参数表里,实例化不需要再传。这也解释了为什么 CipherTransform 的缓存值类型是「构造器」而不是「密钥」:密钥被闭包在构造器里,缓存只负责「同一个过滤器名不要重复解析」。

设计要点:文件密钥、对象密钥、密码实例分别在不同层生成。工厂只算出文件密钥;#buildObjectKey 只在 fetch 一个对象时按 num、gen 现算对象密钥;CipherTransform 只在真正读一段数据时 new 密码实例。三层之间没有共享的可变状态,所以同一个文档可以被多个对象、多条流并发读取而不互相污染。

总结

crypto.js 是 PDF.js 的标准安全处理器实现:向上给 XRef、Parser、Writer 提供「按对象加解密」的接口,向下用自己实现的 RC4 与 AES 完成字节变换,中间夹着两套口令算法与一层对象密钥派生。

它在整条链路中的位置

阶段模块输入输出
读取加密字典XRef.parsetrailer 的 /Encrypt、/ID[0]CipherTransformFactory
口令校验CipherTransformFactory 构造函数口令、/O、/U、/P、/OE、/UE文件密钥 encryptionKey
对象密钥#buildObjectKey对象号、generation、文件密钥5 到 16 字节对象密钥
读字符串CipherTransform.decryptString密文字符串明文字符串
读流CipherTransform.createStream密文 BaseStreamDecryptStream
写回CipherTransform.encryptString明文字符串或流字节带 IV 与填充的密文

用一句话串起来:工厂把加密字典变成一把文件密钥,对象号把文件密钥变成对象密钥,变换门面再把对象密钥变成一个可流式推进的密码实例。

五条设计原则

  1. 原语、算法、分派三层分离。RC4 与 AES 只认识字节数组;PDF17、PDF20 只认识口令与盐;工厂只认识字典;CipherTransform 只认识过滤器名。每层都能单独测试,也不会把 PDF 的版本差异泄漏到密码原语里。
  2. 密钥按生命周期分层。文件密钥随文档存活,对象密钥随一次 fetch 存活,密码实例随一条流或一个字符串存活。任何一层被提前创建或跨对象复用,都会破坏流式解密的顺序性。
  3. 缓存构造器,不缓存实例。CipherTransform.#cipherCache 缓存的是「过滤器名到构造器」的映射。构造器无状态,密钥已被 bind 固定;实例才持有 RC4 置换表、AES 的 IV 与残余块。这样缓存不会把两条流的状态混在一起。
  4. 校验先于解密,失败分层提示。/U、/O 是不可逆的校验值,口令只能通过重新计算来验证;「缺口令」「口令错」「延迟到附件再提示」对应三种不同异常,调用方可以据此决定是弹输入框还是报格式错误。
  5. 兼容优先,规范兜底。/Length 缺失时从 /CF 恢复,字节写成位时左移三位,R 6 的 SASLprep 失败时回退原始口令,AES 的填充不合法时按无填充处理,V 5 的生产者把 CFM 写错时仍按 AES-256 处理。每一条都是「先按规范走,走不通再尽量打开」。

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

相关文章