技术文章

PDFlibPas 在 Delphi 中用 SASLprep 处理 AES-256 密码

一份用非 ASCII 密码加密的 AES-256 PDF,只能在写出它的那个程序里打开,别处都不行。原因几乎总是缺了一步准备工作:ISO 32000-2 §7.6.4.3.3 要求密码在被 UTF-8 编码和哈希之前,先经过 stringprep 的 SASLprep 规范处理。Delphi 和 C++Builder 用的 PDF 库 PDFlibPas,在 EncryptEncryptFileDecryptFile 内部完成这一步准备工作

这篇讲的既不是"密码打错了"的故事,也不是权限位的故事。如果你的用户输入了一个你从没发给他们的密码,你想要的是关于加密 PDF 密码重试的文章里讲的重试机制;如果你想弄清楚一份现有文件到底强制执行了什么,加密与权限审计那篇覆盖了这块内容。而这一篇讲的问题更窄、也更古怪:密码是对的,用户也确实打对了,可文件在别处就是打不开

为什么非 ASCII 密码在一个阅读器里能打开,在另一个里不行

因为两个程序对同一串按键敲出来的内容,哈希的是不同的字节序列。ISO 32000-2 §7.6.4.3.3 里第 6 版的密钥推导,把密码当作 UTF-8 字节,截断到 127 字节,附加一个盐值,再跑加固哈希;结果会拿去和加密字典里的 /U/O 条目做比对。这条链路里没有任何模糊地带。输入里任何一处有一个字节不同,得到的摘要就完全不同,校验失败,阅读器能给出的解释只有一个:密码错误

字节之所以出现分歧,是因为 Unicode 提供了好几种方式来输入看起来一样的密码。一个中文密码,从一种输入法出来可能是预组合字符,从另一种出来可能是兼容形式。一个从文字处理软件里复制出来的德语或法语密码,用户以为那里是一个普通空格,实际上可能是一个不换行空格(U+00A0),或者一个渲染出来什么都不显示的软连字符(U+00AD)。SASLprep 存在的意义,就是在任何人做哈希之前,把这些形式全部折叠成一种规范形式,让每一个遵循规范的实现都能从同一份用户意图里推出同一个密钥

SASLprep 到底对密码做了什么改动

RFC 4013 把 SASLprep 定义为 RFC 3454 中 stringprep 框架的一份规范,它是四个有先后顺序的步骤,而不是一次性的变换。映射排在最前:RFC 3454 表 C.1.2(非 ASCII 空格)会被映射为 U+0020,表 B.1(常被映射为空的字符)则直接被删除。接下来是归一化为 Unicode NFKC,这一步会折叠兼容字符和组合序列。然后是禁止输出检查,凡是落在表 C.2.1 到 C.9 里的字符都会被拒绝。最后,对归一化后的字符串套用 RFC 3454 第 6 节的双向文字规则

PDFlibPas 把整个规范实现在 PDFlibSASLprep 单元里,对外只暴露一个入口点。PLSASLprepPassword 接收原始密码,把处理后的形式写入一个 var 参数,遇到必须拒绝的密码时返回 False。这个函数在顺利路径上刻意做到"什么都不改":一个只含 ASCII 的密码返回时逐字节不变,所以对现有部署没有任何影响

uses
  PDFlibSASLprep;

var
  Prepared: WideString;
begin
  // RFC 4013: mapping, then NFKC, then prohibited output, then the bidi rule
  PLSASLprepPassword('I' + WideChar($00AD) + 'X', Prepared);  // -> 'IX'   B.1 deletes SOFT HYPHEN
  PLSASLprepPassword('a' + WideChar($00A0) + 'b', Prepared);  // -> 'a b'  C.1.2 maps NBSP to U+0020
  PLSASLprepPassword(WideString(WideChar($00AA)), Prepared);  // -> 'a'    NFKC folds ORDINAL INDICATOR
  PLSASLprepPassword(WideString(WideChar($2168)), Prepared);  // -> 'IX'   NFKC folds ROMAN NUMERAL NINE
  PLSASLprepPassword('user', Prepared);                       // -> 'user' ASCII is never touched
end;

表格没能解决的 U+200B 歧义

有一个码点同时落在两张 RFC 3454 表里,而这两张表的意见并不一致。零宽空格(U+200B)落在 C.1.2 的 U+2000 到 U+200B 范围内,规则说要把它映射为 U+0020;它同时也落在 B.1 的 U+200B 到 U+200D 范围内,规则说要把它删除。按不同顺序去读映射这一步,同一个密码会得到不同的字节输出:a+U+200B+b 按 C.1.2 处理会变成 a b,按 B.1 处理会变成 ab。RFC 4013 两张表都提到了,却没说哪个优先,所以这是规范本身真实存在的歧义,而不是理解错误。PDFlibPas 先检测是否属于 C.1.2,因此把 U+200B 映射为空格,这也是其他广泛部署的 stringprep 实现所采用的行为;和它们保持一致才是这里唯一重要的事,因为目标是和客户碰巧用的那个阅读器在字节层面保持一致

读取旧文件:先试准备后的形式,再试原始形式

这次修复自己制造了一个兼容性问题。这次改动之前写出的每一份 AES-256 文件,哈希的都是原始 UTF-8 密码,所以如果让读取端严格遵循规范,就会把客户挡在自己的归档文件外面。PDFlibPas 在读取一侧的解决办法是按顺序尝试两个候选值。TPDFDocument.SetPassword 会构建一个候选列表,先放准备后的形式,再退回到原始形式,而且只有在文档确实是 AES-256 且两种形式不同的情况下,才会加入准备后的那个条目。对于一个纯 ASCII 密码,两种形式完全相同,列表里只有一个条目,整套机制的代价就是一次字符串比较。DecryptFile 在它的 AES-256 直接重写路径上做的事情一样,会先用准备后的密码去调用 PLDirectDecryptFileAES256

var
  Lib: TPDFlib;
  Bytes: AnsiString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.DrawText(100, 100, 'saslprep roundtrip');
    // Strength 3 and 4 are the two AES-256 values; both are prepared before hashing
    Lib.Encrypt('ow' + WideChar($00AD) + 'ner', 'pa' + WideChar($00AD) + 'ss', 4,
      Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1));
    Bytes := Lib.SaveToString;
  finally
    Lib.Free;
  end;

  Lib := TPDFlib.Create;
  try
    // 'pass' is what SASLprep produced and what any conforming reader computes,
    // so the plain ASCII form opens a file created with the soft-hyphen form
    if Lib.LoadFromString(Bytes, 'pass') = 1 then
      Caption := IntToStr(Lib.PageCount);
  finally
    Lib.Free;
  end;
end;

这个回退机制里有一道值得照搬的防护。DecryptFile 里的第二次尝试,只有在准备后的形式和原始形式确实不同、并且第一次尝试没有报出硬性错误码时才会执行。结构性失败意味着输入本身已经损坏,或者根本不是你以为的那个加密版本,用另一个密码去重试一份已经损坏的文件,只会白白再对充满恶意的输入做一遍完整解析;这个反应背后的道理在安全解析不可信 PDF 的笔记里有讲述。另外要注意,写入一侧没有任何回退,而这种不对称是刻意为之的。读取要包容历史,写入不需要:每一份新写出的 AES-256 文件都拿到的是符合规范的字节

哪些密码会被直接拒绝,错误码 604 是什么

SASLprep 可以完全拒绝一个密码,一旦发生这种情况,加密必须大声失败,而不是悄悄换个东西顶上。只要 Strength 是 3 或 4,EncryptEncryptFile 就会同时处理所有者密码和用户密码,拒绝时返回 0,并把 LastErrorCode 设为 PDFLIB_ERROR_PASSWORD_SASLPREP,也就是 604。触发它的输入分两类。禁止输出表会拒绝控制字符(C.2.1 和 C.2.2)、私用区码点(C.3)、非字符(C.4)、孤立代理项(C.5)、U+FFFD(C.6)、表意文字描述字符(C.7),以及显示控制和标记范围(C.8 和 C.9)。此外,RFC 3454 第 6 节的双向文字规则会拒绝任何包含表 D.1 里 RandALCat 字符的字符串,除非该字符串同时以这类字符开头和结尾,且完全不包含从左到右书写的字母

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    // U+0007 is a C.2.1 control character, so preparation refuses the password
    if Lib.Encrypt('owner', 'bad' + WideChar($0007), 4,
         Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1)) = 0 then
    begin
      if Lib.LastErrorCode = PDFLIB_ERROR_PASSWORD_SASLPREP then  // 604
        ShowMessage('The password contains characters that PDF encryption does not permit.');
    end;
  finally
    Lib.Free;
  end;
end;

那条双向文字规则会是最让你的支持团队意外的一条。一个以西方数字结尾的阿拉伯语或希伯来语密码,或者中间夹了一个孤零零的拉丁字母,会被规范拒绝,尽管它在输入框里看起来完全合理。请把 604 呈现成一条关于密码字符本身的提示,而不是一句笼统的加密失败,否则会有人花一下午去你的密钥推导代码里找根本不存在的 bug

坦诚的局限:近似的 NFKC、近似的 LCat,以及一个 Delphi 陷阱

实现里有两处是近似处理,两处都值得坦白说清楚,而不是藏起来。NFKC 归一化是靠 Windows 的 NormalizeString API 完成的,这个 API 是从 Normaliz.dll 动态加载的。当这个库不可用时,映射后的字符串会被原样使用而不做归一化,也就是说映射和禁止检查这两步照常执行,只是兼容折叠不会发生。实际情况是,这个 DLL 从 Vista 起就随每个 Windows 版本一起分发,所以这条降级路径只是 Vista 之前和非 Windows 环境才需要关心的问题,而不是现实中会常遇到的情况,但一个依赖 NFKC 折叠的密码,在那种环境下确实会产生不同的字节,这是一个真实存在、只是很少见的分歧点。双向文字检查是第二处近似:检测 LCat 字符用的是常见字母范围,而不是完整的 RFC 3454 表 D.2,而这个误差的方向恰恰让它可以被接受。漏判一个 LCat 字符,只会让双向文字规则在本该拒绝的地方放行,绝不会反过来,而且它完全不触及映射或归一化这两步,所以一个被接受的密码,其准备后的字节序列不会因此改变。因此残余风险是一种策略分歧,而不是字节分歧:一个更严格的实现会直接拒绝的、用了生僻文字的密码。只要双方都接受某个密码,双方哈希出来的结果就完全一致,而这正是互操作性真正依赖的那条性质

最后,还有一个 Delphi 语法陷阱,没踩过的人会白白搭进去一个小时。当一个函数返回过程类型时,不带括号地赋值并不会调用它。编译器会把 Proc := GetNormalizeProc; 读成取 GetNormalizeProc 本身的地址,然后报出 E2009,抱怨调用约定不一致,帮助信息完全没用,原因是这个访问函数用的是默认调用约定,而导入的 API 类型是 stdcall。空括号是必须要写的

type
  TNormalizeString = function(NormForm: Integer; SrcString: PWideChar; SrcLength: Integer;
    DstString: PWideChar; DstLength: Integer): Integer; stdcall;

function GetNormalizeProc: TNormalizeString;   // loads Normaliz.dll on first use
...
var
  Proc: TNormalizeString;
begin
  // Proc := GetNormalizeProc;   // E2009: reads as @GetNormalizeProc, conventions differ
  Proc := GetNormalizeProc();    // correct: calls the accessor and assigns its result
  if not Assigned(Proc) then
    Exit;                        // no NFKC available, mapped string is used as-is
end;

密码准备是那种从不会出现在功能列表里、却能决定一份加密文档到了另一个语言环境的客户手里还能不能打开的细节。这里介绍的 EncryptEncryptFileDecryptFileSetPassword 入口点,是 losLab PDF Developer Library Pascal Edition 的一部分,面向 Delphi 和 C++Builder,其产品页带有完整的加密参考和完整的错误码表