PDF.js 源码解读: crypto
2026年10月27日 · StreamPDF Team

前言
这是 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-111 | ARCFourCipher、NullCipher | RC4 的 KSA/PRGA 与「不加密」的恒等实现 |
| AES 基类 | 113-525 | AESBaseCipher | S 盒、T 表、轮函数、CBC 链接与填充处理 |
| AES 密钥扩展 | 527-654 | AES128Cipher、AES256Cipher | 10 轮与 14 轮的轮密钥生成 |
| 口令校验算法 | 656-756 | PDFBase、PDF17、PDF20 | R5/R6 的 O/U 校验、文件密钥解包、Algorithm 2.B |
| 变换门面 | 758-855 | CipherTransform | 按密码过滤器分派、流式解密、字符串加解密 |
| 口令编码 | 857-864 | utf8PasswordToBytes | JavaScript 字符串转 UTF-8 字节 |
| 安全处理器工厂 | 866-1350 | CipherTransformFactory | 解析加密字典、校验口令、派生文件密钥、生成变换 |
对外真正被别的模块调用的入口只有五个:
| 入口 | 调用方 | 作用 |
|---|---|---|
new CipherTransformFactory(dict, fileId, pwd) | XRef.parse | 每个文档一个,解析加密字典并算出文件密钥 |
factory.encryptionKey | CipherTransformFactory 自身 | 文件级密钥,供对象密钥派生使用 |
factory.createCipherTransform(num, gen) | XRef.fetchUncompressed | 每个间接对象一个变换 |
transform.decryptString / transform.createStream | Parser | 读字符串与流 |
transform.encryptString | Writer | 写回字符串与流 |
它在整条链路里的位置:
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);
}这段代码里有三个信号,正好对应工厂的三个构造参数:
/Encrypt字典本身必须不参与解密。字典里的/O、/U是校验值,/CF可能包含间接对象;如果为了读加密字典又要先解密,就会形成死循环。suppressEncryption就是这条链路的断路器,#prepareKeyData里对/CF也做了同样的处理。fileId来自/ID[0],允许为空。它参与密钥派生:同一个密码、同一份权限,只要文件标识不同,派生出的文件密钥就不同。这是防止「一份密钥打天下」的盐。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 对两者都做了纠偏,这一点后文讲构造函数时会展开。
字典键到字段
工厂构造函数做的事,本质上是把这张表搬进实例字段:
| 加密字典键 | 源码字段 / 局部变量 | 含义 | 谁在使用 |
|---|---|---|---|
/Filter | filterName | 安全处理器名称,必须是 Standard | 构造函数入口校验 |
/V | algorithm | 加密机制版本,只允许 1、2、4、5 | 构造函数、createCipherTransform |
/R | revision | 口令算法修订号,常见 2、3、4、5、6 | #prepareKeyData、#createEncryptionKey20 |
/Length | keyLength | 文件密钥长度,单位位 | #prepareKeyData、#buildObjectKey |
/CF | cf | 密码过滤器字典,仅 V 大于等于 4 时读取 | resolveCipher |
/StmF | stmf | 流的默认密码过滤器名 | CipherTransform 的 streamFilterName |
/StrF | strf | 字符串的默认密码过滤器名 | CipherTransform 的 stringFilterName |
/EFF | eff | 内嵌文件的默认密码过滤器名,缺省等于 StmF | CipherTransform.embeddedFilterName |
/O | ownerBytes、ownerPassword | 所有者口令校验值;前 32 字节参与校验,后 16 字节是盐 | 两条口令路径 |
/U | userBytes、userPassword | 用户口令校验值;前 32 字节参与校验,后续字节是盐 | 两条口令路径 |
/OE | ownerEncryption | 用所有者密钥加密过的文件密钥,仅 V 5 | #createEncryptionKey20 |
/UE | userEncryption | 用用户密钥加密过的文件密钥,仅 V 5 | #createEncryptionKey20 |
/Perms | perms | 权限的加密副本,仅 V 5 | 传给 #createEncryptionKey20,本版未使用 |
/P | flags | 权限位,按 32 位有符号整数读取 | #prepareKeyData 的哈希输入 |
/EncryptMetadata | encryptMetadata | 元数据流是否加密,仅 V 4、V 5 有意义 | 哈希输入、Catalog 的取流参数 |
/P 出现在哈希里这一点经常被忽略。它意味着:只改权限位而不改密码,校验值就对不上。权限声明因此被绑进了密钥派生,而不是一张可以随手编辑的配置表。
/V 与 /R 是两条独立的坐标
初学者容易把 /V 与 /R 当成同一个「版本号」。它们描述的是两件不同的事:
/V决定机制:用不用密码过滤器、用 RC4 还是 AES、文件密钥多长。/R决定口令算法:/O、/U怎么算,校验值有多长,密钥派生做几轮。
同一个机制可以配不同的修订号,所以工厂里是两条分支叠加:先看 algorithm 走哪条密钥派生路径,再由 revision 决定哈希算法与迭代次数。把支持范围画成表:
/V | 机制 | 每对象密钥 | 常见 /R | 文件密钥长度 |
|---|---|---|---|---|
| 1 | RC4,固定 40 位 | 有 | 2 | 5 字节 |
| 2 | RC4,长度由 /Length 指定 | 有 | 3 | 5 到 16 字节 |
| 4 | 密码过滤器,CFM 可为 V2、AESV2、None | 有 | 4 | 通常 16 字节 |
| 5 | AES-256,固定使用文件密钥 | 无 | 5、6 | 32 字节 |
/V 3 在规范里是「RC4 128 位」的早期写法,但这一版 PDF.js 直接拒绝,构造函数只接受 1、2、4、5。读源码时不要以为漏写了分支:createCipherTransform 末尾那段注释写的是 algorithms 1 and 2,V 3 根本到不了那里。
口令算法的支持范围:
/R | 口令算法 | 校验值布局 | 密钥长度 |
|---|---|---|---|
| 2 | Algorithm 2:MD5 一次,RC4 一次 | /O、/U 各 32 字节 | 40 位 |
| 3 | Algorithm 2 的加强版:MD5 五十次,RC4 二十轮 | /O、/U 各 32 字节 | 40 到 128 位 |
| 4 | 与 R 3 相同,增加密码过滤器与 /EncryptMetadata | /O、/U 各 32 字节 | 通常 128 位 |
| 5 | SHA-256 校验,盐分离,/OE、/UE 加密文件密钥 | /O、/U 各 48 字节,外加 /OE、/UE | 256 位 |
| 6 | SHA-256 起步,Algorithm 2.B 迭代,密码先做 SASLprep | 同 R 5 | 256 位 |
从字典到运行时对象
把上表落到类上,映射关系是这样的:
| 字典信息 | 运行时承载 |
|---|---|
Filter Standard | CipherTransformFactory.filterName |
V、R、Length | algorithm、revision、派生出的 encryptionKey |
CF、StmF、StrF、EFF | cf、stmf、strf、eff,最终进入 CipherTransform |
O、U、OE、UE、P | 构造函数里的局部字节数组,喂给 #prepareKeyData 或 #createEncryptionKey20 |
CFM V2 | ARCFourCipher 加对象密钥 |
CFM AESV2 | AES128Cipher 加对象密钥 |
CFM AESV3 或 V 5 | AES256Cipher 加文件密钥 |
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;
}逐行看:
- 先建一张恒等置换:
s[i] = i,256 个字节刚好覆盖 0 到 255。 j初始为 0,每一轮加上当前字节s[i]与密钥字节key[i % keyLength]。密钥通常比 256 短,所以按i % keyLength循环取用。& 0xff把累加结果截断回一个字节。这不是「取模优化」的随意写法,而是 RC4 定义的一部分:所有下标都必须落在 0 到 255。- 交换
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 | 字节数组(第二参数被忽略) | 转调 encryptBlock | DecryptStream、decryptString |
encrypt | 字节数组 | 转调 encryptBlock | encryptString、对象密钥校验 |
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 使用。
| 常量 | 类型 | 项数 | 作用 |
|---|---|---|---|
_s | Uint8Array | 256 | SubBytes,字节替换 |
_inv_s | Uint8Array | 256 | InvSubBytes,解密时的反替换 |
_mix | Uint32Array | 256 | InvMixColumns 的列贡献,按 32 位打包 |
_mixCol | Uint8Array | 256 | xtime,加密时乘以 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-128 | AES-256 | 含义 |
|---|---|---|---|
_cyclesOfRepetition | 10 | 14 | 轮数,决定轮函数循环几次 |
_keySize | 160 | 224 | 最后一轮轮密钥在扩展密钥里的字节偏移 |
_key | 176 字节 | 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 的等价逆密码,而不是把加密步骤简单倒放。顺序如下:
- 先用最后一轮轮密钥做
AddRoundKey,下标从_keySize开始。 - 中间轮从
_cyclesOfRepetition - 1递减到 1,每轮依次做InvShiftRows、InvSubBytes、AddRoundKey(第 i 轮密钥)、InvMixColumns。 - 最后一轮只做
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;
}加密分三段:
- 初始轮密钥加。明文先与第一轮轮密钥异或,轮密钥下标从 0 开始。
- 中间轮。从 1 到
_cyclesOfRepetition - 1,每轮依次做SubBytes、ShiftRows、MixColumns、AddRoundKey(第 i 轮密钥)。 - 末轮。只做
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、AddRoundKey | InvShiftRows、InvSubBytes、AddRoundKey、InvMixColumns |
| 末轮 | SubBytes、ShiftRows、AddRoundKey | InvShiftRows、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;
}与解密相比,有三处不同:
- 没有
finalize。加密不做去填充,也不在末尾补填充;填充由调用方负责。encryptString会先把字符串补齐到 16 的倍数,PDF20的 Algorithm 2.B 则明确要求不带填充。 - 没有自我替换。每次调用都接收
iv参数,缺省时用全零 IV。this.iv在结尾被保存,但下一次调用不会读取它作为默认值;真正决定链接起点的是参数。 - 输出长度只算整块。不满 16 字节的尾巴留在
this.buffer里,不产生输出。没有finalize就意味着这段尾巴永远不会被冲出;调用方必须保证输入是块对齐的,否则最后几个字节会静默丢失。
| 维度 | decryptBlock | encrypt |
|---|---|---|
| IV 来源 | 参数,或密文头部 16 字节 | 参数,缺省全零 |
| 首次调用 | 可能是「收集 IV」阶段,返回空数组 | 直接处理,无特殊阶段 |
| 链接方向 | 密文块异或明文块 | 明文块异或前一块密文 |
| 末块处理 | finalize 为真时去 PKCS#7 填充 | 不填充,残余留在缓冲 |
| 方法替换 | 首次调用后替换为 _decryptBlock2 | 不替换 |
| 典型调用方 | 流、字符串、/OE、/UE | encryptString、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);
}| 项目 | AES128Cipher | AES256Cipher |
|---|---|---|
| 输入密钥长度 | 16 字节(128 位) | 32 字节(256 位) |
| 轮数 | 10 | 14 |
| 扩展密钥长度 | 176 字节 | 240 字节 |
_keySize | 160 | 224 |
| 每轮密钥字数 | 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;
}逐段看:
result长 176 字节,前 16 字节直接放入初始密钥。- 从第 16 字节开始,每轮取上一组密钥的最后 4 字节(
j - 4到j - 1),按t4、t1、t2、t3的顺序读入。这里的命名看起来反了,其实是按「32 位字的高字节到低字节」排列:t4是最高位,t1是最低位。RotWord在逻辑上把[t4, t1, t2, t3]左移一个字节,得到[t1, t2, t3, t4]。 - 四个字节各自过 S 盒,这就是
SubWord。 - 最低位
t1异或轮常数rcon[i]。 - 内层循环执行四次,每次写一个字节,并与前 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 逻辑 | 轮数、轮密钥、密钥长度 |
AES128Cipher | 10 轮、176 字节轮密钥、rcon 表 | 轮函数、CBC |
AES256Cipher | 14 轮、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));
}
}两个校验方法的差别在两个地方:
| 方法 | 盐的长度 | 额外输入 | 哈希输入总长 | 比较对象 |
|---|---|---|---|---|
checkUserPassword | 8 字节 | 无 | 口令长度加 8 | /U 的前 32 字节 |
checkOwnerPassword | 8 字节 | /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-31 | 32 | 所有者口令校验值 | 用户口令校验值 |
| 32-39 | 8 | 所有者校验盐 | 用户校验盐 |
| 40-47 | 8 | 所有者密钥盐 | 用户密钥盐 |
| 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-256 | SHA-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 | 无 | utf8PasswordToBytes | algorithm === 5 分支 |
R 6 | saslPrep,再做 UTF-8 | utf8PasswordToBytes | revision === 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);
}
);
}默认过滤器的选择有优先顺序:
- 调用方显式传了
cryptFilterName(例如/Crypt过滤器/DecodeParms里的/Name),直接用。 - 否则,如果这是内嵌文件流且
/EFF存在,用/EFF。 - 否则用
/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 分支做三件事:
- 补齐。
pad = 16 - (strLen % 16),补pad个值为pad的字节。当长度正好是 16 的倍数时,pad等于 16,补一整块0x10,与规范一致。这里的strLen是 JavaScript 字符串长度,也就是字节数,因为 PDF 字符串每个字符只占一个字节。 - 生成 IV。
crypto.getRandomValues(iv)用浏览器的密码学随机源生成 16 字节。每次加密都用新 IV,所以同一个字符串加密两次会得到不同密文;这是 CBC 模式的语义要求,也避免了「相同明文产生相同密文」的泄漏。 - 前缀 IV。 密文前面拼上 16 字节 IV,再整体转成字符串。读取方
decryptBlock首次调用时正好把这个前缀当作 IV 消费掉,读写两侧的格式在这里闭环。
RC4 分支没有补齐、没有 IV、没有前缀:cipher.encrypt(data) 直接异或,长度与输入相同。这也解释了为什么同一个 encryptString 要按 instanceof AESBaseCipher 分支:两种密码对「字符串在线路上长什么样」的约定不同。
| 操作 | 入口 | 密码实例 | 过滤器来源 | 末块/填充 |
|---|---|---|---|---|
| 读流 | createStream | 每条流新建 | 显式名、/EFF、/StmF | DecryptStream 传 finalize |
| 读字符串 | decryptString | 每次新建 | /StrF | 固定 true |
| 写字符串 | encryptString | 每次新建 | /StrF | AES 补齐并前置随机 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,或名字不是 Standard | FormatError: 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 | /P | 4 字节,小端 | 权限位 |
| 4 | 文件标识 | 与 /ID[0] 等长 | 文件级盐 |
| 5 | 四个 0xff | 4 字节,可选 | 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 之后的校验值是:
- 用默认填充与文件标识拼一段数据,做 MD5。
- 用文件密钥做 RC4,加密这段 MD5。
- 再用
key[i] ^ j(j 从 1 到 19)派生出的 19 把密钥,依次加密上一次结果。 - 把最终结果与
/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 为 None | NullCipher | 无 |
V 5,或 CFM 为 AESV3 | AES256Cipher 绑定文件密钥 | 文件密钥,32 字节 |
CFM 为 V2 | ARCFourCipher 绑定对象密钥 | #buildObjectKey |
CFM 为 AESV2 | AES128Cipher 绑定对象密钥 | #buildObjectKey |
| 其它 | 抛 FormatError: Unknown crypto method | 无 |
几个容易看漏的点:
NullCipher在密钥检查之前。 即使文档没有口令(encryptionKey为null),只要某个过滤器写的是None,这一段仍然可以解密——因为本来就不需要密钥。这正是延迟提示能工作的前提:正文用Identity,附件用EFOpen,读正文时不碰密钥。V 5优先于CFM。 规范要求V 5一律用 AES-256,但有些生成器把CFM写成AESV2。源码先判algorithm === 5,直接返回AES256Cipher,避免被错误的CFM带到 128 位分支(注释里的 bug 2046659)。AESV3也用文件密钥。 它不调用#buildObjectKey,因为 32 字节文件密钥已经足够强,规范不再要求对象级派生。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.parse | trailer 的 /Encrypt、/ID[0] | CipherTransformFactory |
| 口令校验 | CipherTransformFactory 构造函数 | 口令、/O、/U、/P、/OE、/UE | 文件密钥 encryptionKey |
| 对象密钥 | #buildObjectKey | 对象号、generation、文件密钥 | 5 到 16 字节对象密钥 |
| 读字符串 | CipherTransform.decryptString | 密文字符串 | 明文字符串 |
| 读流 | CipherTransform.createStream | 密文 BaseStream | DecryptStream |
| 写回 | CipherTransform.encryptString | 明文字符串或流字节 | 带 IV 与填充的密文 |
用一句话串起来:工厂把加密字典变成一把文件密钥,对象号把文件密钥变成对象密钥,变换门面再把对象密钥变成一个可流式推进的密码实例。
五条设计原则
- 原语、算法、分派三层分离。RC4 与 AES 只认识字节数组;
PDF17、PDF20只认识口令与盐;工厂只认识字典;CipherTransform只认识过滤器名。每层都能单独测试,也不会把 PDF 的版本差异泄漏到密码原语里。 - 密钥按生命周期分层。文件密钥随文档存活,对象密钥随一次
fetch存活,密码实例随一条流或一个字符串存活。任何一层被提前创建或跨对象复用,都会破坏流式解密的顺序性。 - 缓存构造器,不缓存实例。
CipherTransform.#cipherCache缓存的是「过滤器名到构造器」的映射。构造器无状态,密钥已被bind固定;实例才持有 RC4 置换表、AES 的 IV 与残余块。这样缓存不会把两条流的状态混在一起。 - 校验先于解密,失败分层提示。
/U、/O是不可逆的校验值,口令只能通过重新计算来验证;「缺口令」「口令错」「延迟到附件再提示」对应三种不同异常,调用方可以据此决定是弹输入框还是报格式错误。 - 兼容优先,规范兜底。
/Length缺失时从/CF恢复,字节写成位时左移三位,R 6的 SASLprep 失败时回退原始口令,AES 的填充不合法时按无填充处理,V 5的生产者把CFM写错时仍按 AES-256 处理。每一条都是「先按规范走,走不通再尽量打开」。
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- PDF.js 源码解读: pdf_manager2026年10月27日
- PDF.js 源码解读: object_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_core2026年10月27日
- PDF.js 源码解读: core_utils2026年10月27日
- PDF.js 源码解读: primitives2026年10月27日
- PDF.js 源码解读: worker_stream2026年10月27日
- PDF.js 源码解读: ccitt_stream2026年10月27日
- PDF.js 源码解读: jpx_stream2026年10月27日
- PDF.js 源码解读: jbig2_stream2026年10月27日
- PDF.js 源码解读: jpeg_stream2026年10月27日
- PDF.js 源码解读: decrypt_stream2026年10月27日
- PDF.js 源码解读: brotli_stream2026年10月27日
- PDF.js 源码解读: run_length_stream2026年10月27日
- PDF.js 源码解读: ascii_hex_stream2026年10月27日
- PDF.js 源码解读: ascii_85_stream2026年10月27日
- PDF.js 源码解读: lzw_stream2026年10月27日
- PDF.js 源码解读: predictor_stream2026年10月27日
- PDF.js 源码解读: flate_stream2026年10月27日
- PDF.js 源码解读: decode_stream2026年10月27日
- PDF.js 源码解读: chunked_stream2026年10月27日
- PDF.js 源码解读: stream2026年10月27日
- PDF.js 源码解读: base_stream2026年10月27日
- PDF.js 深入浅出 11: Parser2026年10月26日
- PDF.js 深入浅出 10: XRef2026年10月26日
- PDF.js 深入浅出 9: CanvasGraphics2026年10月26日
- PDF.js 深入浅出 8: Document 与 Page2026年10月26日
- AI 时代, 我们到底应该做什么才能活下去(StreamPDF 创立有感)2026年10月26日
- PDF.js 深入浅出 7: PartialEvaluator2026年10月25日
- PDF.js 深入浅出 6: PDFManager.initDocument2026年10月25日
- PDF.js 深入浅出 5: PDFFetchStream 和 PDFWorkerStream2026年10月25日
- PDF.js 深入浅出 4: MessageHandler2026年10月22日
- PDF.js 深入浅出 3: getViewport & render2026年10月22日
- PDF.js 深入浅出 2: getDocument & getPage2026年10月19日
- PDF.js 深入浅出 1: PDF.js 的架构设计2026年10月16日