Java常用类库-28-Base64严格度与摘要认证边界
导入令牌需要先确定字节协议
商品导出系统把二进制校验值放进 URL,把文件摘要放进日志,并给回调请求计算认证码。这些字段看起来都是一段字符,但要求不同:Base64 解决字节如何表示,摘要用于得到固定长度指纹,HMAC 则依赖密钥认证消息。把它们都叫作“加密字符串”,会使协议中的保密性、完整性与来源认证混在一起。
本篇固定 Commons Codec 1.22.1,提交 dc8f6c832a30f524bf12a5ae7ab013e4ad7c088f,对照 JDK 8 的 Base64、MessageDigest 与 Mac。完整测试见 Chapter28Test.java,命令见 RUN.md。测试只使用公开向量和合成字节,没有生产密钥。
首先确定协议:输入字符串如何变成字节,使用哪种字母表,是否允许填充和空白,多个不同文本能否代表同一字节串,校验值覆盖哪些字段。工具类可以完成转换,却不能替接口协议作出这些选择。
URL 字母表与填充是两个参数
实验使用两个字节 FB FF。标准 Base64 编码为 +/8=,Commons Codec 的 URL-safe 便捷方法编码为 -_8。字母表把加号与斜杠换成连字符和下划线,这个方法同时省略了填充等号。不能只根据方法名中的 URL 推断所有库都会采用相同的填充规则。
JDK URL decoder 能将本例的 -_8 还原为原始两个字节。这个互通断言只覆盖指定输入与解码器;它不说明标准解码器也应接受 URL 字母表,或协议可以随意混用两种表示。发送方与接收方应该明确约定变体,再分别测试拒绝输入。
字符集是另一层选择。中文商品文本先使用 UTF-8 得到字节,再执行 Base64;解码后得到的仍然是字节,只有按相同字符集构造文本才能恢复原值。Base64 自身不记录 UTF-8、GBK 或其他字符集信息,也不会修复上游已经错误解码的字符串。
Hex 同样只是表示方法。实验的 000fff 解码后是 00、0F、FF,前导零必须保留。把这种值先变成整数再输出,可能丢失前导零或引入符号语义。对于协议字段,直接在 byte[] 与指定字符表示之间转换,更容易保持精确长度。
STRICT 没有拒绝所有非字母表字符
最容易误判的地方是 CodecPolicy.STRICT。实验中,宽松模式将 TR== 解码为字节 M,而严格模式抛出 IllegalArgumentException。TQ== 才是 M 的规范编码;TR 中被舍弃的尾部比特并非零,严格模式会检查这一条件。
但严格实例解码 T!Q== 仍然得到 M。新提供的 decodeBase64Standard 便捷方法也接受这个带感叹号的文本。相比之下,JDK basic decoder 对同一输入抛出 IllegalArgumentException。因此,把 Commons STRICT 解释成“只允许标准字母表字符”是不准确的。
Base64 固定源码在解码循环中只把有效解码表项累加进工作区,对不支持的字符继续处理;尾块校验另行检查剩余比特和可能的尾块长度。严格策略影响后者,没有把所有被跳过字符改成异常。
上游 Base64Test中的尾比特测试枚举最后一个编码字符,验证不可丢弃的有效比特。它解释了 strict 的重要用途,也帮助限定这个单词的实际范围。本地反例进一步验证了字符跳过行为,避免只从枚举名称推导契约。
| 输入 | Commons LENIENT | Commons STRICT | JDK basic |
|---|---|---|---|
TR== |
本例得到 M | 拒绝非零尾比特 | 本篇未以此输入验收 |
T!Q== |
本篇未单独断言 | 得到 M | 拒绝非法字符 |
-_8 |
不在本表比较范围 | 不在本表比较范围 | 应选择 URL decoder |
表中没有把未运行的组合补成猜测结果。协议评审最需要的是接收器到底允许哪些输入,而不是给某个库贴上笼统的“严格”或“宽松”标签。
规范文本与可解码文本不是同一个集合
如果令牌被用于缓存键、数据库唯一键或签名原文,相同字节可以由多个文本表示就可能产生不一致。一处按照原始文本比较,另一处解码后比较,两处看到的身份关系不同。是否允许空白、是否要求填充、是否接受非规范尾比特,都应该写入协议。
一种可迁移模式是将解码与规范形式验证分开。先选定接收规则,再把解码结果用唯一指定的编码方式重新编码,按协议要求比较原始文本是否一致。这个模式会拒绝某些原本可解码但不规范的形式;是否采用它,应由接口兼容要求决定,而不是作为无条件的自动清理。
重编码比较也不能替代输入长度限制。一个带大量无关字符的宽松输入,在得到很短字节结果之前,仍然需要扫描原始字符串。业务应先限制编码文本长度和解码后字节上限,尤其不要把“结果只有几十字节”当成输入成本也只有几十字节。
本地实验没有实现一个完整令牌协议,也没有测试所有填充位置与换行组合。其结论是已选两种反例确实区分了尾比特政策和字母表政策。把实验扩展为接口验收时,需要加入该接口明确允许与拒绝的全部表示形式。
摘要不能证明是谁生成了文件
测试计算 ASCII 字节 abc 的 SHA-256,预期为 ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad。固定向量验证算法名称、输入字节与十六进制输出一致,比只检查输出长度为 64 更有区分力。错误算法或错误输入也可能输出一个看起来合理的长字符串。
DigestUtils 固定源码围绕 JCA MessageDigest 提供便捷调用。便利方法减少获取算法与编码结果的样板,但摘要依然不需要秘密。能够修改文件的人通常也能够重新计算公开摘要,所以单独发送文件及其普通摘要,不能据此证明文件来自某个可信发布者。
如果摘要用于检测传输损坏,接收方还需要知道预期摘要来自哪里。如果预期值与文件经由同一不可信渠道一并被替换,比较通过只说明二者相互匹配。文件指纹、可信清单和消息认证分别承担不同职责,不能通过换一个摘要工具类合并这些职责。
字符规范化也应发生在摘要协议定义中。视觉上相同的文本可能有不同字节表示,换行 LF 与 CRLF 也会改变摘要。对原始文件求摘要通常应直接读取文件字节;对结构化请求认证则要先确定字段顺序、分隔与编码方式,不能在发送端和接收端分别依赖默认 toString。
HMAC 的密钥与消息必须按公开向量逐字节核对
HMAC-SHA-256 实验采用 RFC 4231 第 4.2 节:密钥是重复二十次的字节 0B,消息为 ASCII 的 Hi There。预期认证码为 b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7。测试直接构造二十字节数组,没有把字符 0、b 组成的字符串误当成密钥本体。
HmacUtils 固定源码对 JCA Mac 做封装。它不能决定密钥从何处获得、如何轮换,也不能自动决定时间戳、请求路径和业务字段是否应该进入认证范围。这些条件属于上层协议。
认证码只有在验证方按同一协议重新计算并比较后才产生作用。仅仅生成一段 hmacHex 并写进请求,并不能说明服务端真正验签,更不能说明已具备防重放能力。时间窗口、随机数或请求序号如果是协议的一部分,应被认证覆盖,并由验证端执行相应状态检查。
第二项可迁移模式是用已知向量验证工具,用协议测试验证系统。公开向量可以发现算法、密钥字节和编码错误;篡改字段、缺失认证码与重复请求是否被拒绝,则需要服务端验收。本组实验只完成前一层,不把库函数返回正确值写成完整认证系统已验证。
JDK 足够时保留标准 API
编码文本的大小写也依赖具体格式。十六进制可在协议允许时统一大小写,Base64 字母大小写则代表不同的六比特值。为方便比较而对所有校验字符串执行 toLowerCase,会直接破坏 Base64 数据;规范化必须对应所选编码规则。
摘要向量还可以与上游 DigestUtilsTest交叉核对。该测试同时覆盖 abc 字符串与 UTF-8 字节形式;本地明确使用 ASCII,是因为这个公开向量只包含 ASCII 字符,两种编码在此输入上给出相同字节。不能把这种相等推广到所有默认字符集。
大文件摘要应通过流或分块更新完成,不必先将完整文件转换为字符串。把二进制按某字符集解码,再对结果重新编码求摘要,计算对象已经可能变化。换行转换、替换字符和 BOM 去除都可能改变字节;是否允许这些变化,应在“摘要覆盖原始文件还是规范化内容”这个问题上先作选择。
认证对象还需要避免字段拼接歧义。两个字段分别为 ab、c,与 a、bc,直接连接都会得到 abc;算法正确也无法区分这种上游表示冲突。消息协议应规定长度前缀、无歧义的结构化序列化或其他确定形式,并固定字段是否可缺省。单纯在字段之间随意放一个字符,如果字段自身也允许该字符,同样需要转义或长度规则。
密钥并不因为经过 Base64 就得到保密保护。Base64 能被任何知道规则的一方逆转换,日志中输出编码后的密钥仍然暴露密钥内容。本组公开测试向量可以完整展示,但生产诊断更适合记录密钥版本标识和算法标识,不应复用测试中直接打印字节的习惯。密钥生成、存储与轮换需要独立的系统设计,不能由 HmacUtils 构造器代替。
JDK 8 已提供 basic、URL 与 MIME Base64 变体,也提供 MessageDigest 和 Mac。若项目仅使用这些标准算法,直接依赖 JDK 可以减少工具层。Commons Codec 的 Hex、便捷摘要与已有代码一致性可能仍有价值,但选择前应核对异常、字符集和解码接受范围,而不只比较调用行数。
本组测试在双 JDK 上运行三个场景:编码变体与 UTF-8 往返、strict 尾比特与非法字符反例、摘要和 HMAC 固定向量及 Hex 奇数长度拒绝。Hex 输入 0 抛 DecoderException,提醒调用方格式校验失败不能被默默补零,否则接收到的协议值已经被修改。
手算题:二十字节 0B 与四十字符的十六进制文本是否是同一密钥?不是,后者只有经过 Hex 解码后才得到前者。改动练习是用 JDK Mac 对同一 RFC 向量计算结果,再增加一个消息字节,断言结果不同;这个练习验证字节敏感性,仍不替代服务端验签测试。
| 判断关键词 | 可迁移模式 | 具体选择 |
|---|---|---|
| Base64 变体 | 表示协议独立于数据含义 | 固定字母表、填充与字符集 |
| STRICT | 用具体拒绝条件解释策略 | 尾比特与非法字符分别验收 |
| 摘要 | 指纹与来源认证分开 | 核对预期摘要的可信来源 |
| HMAC | 向量验证与协议验证分层 | 固定密钥字节,另测服务端拒绝 |
可独立复跑的 Maven 项目见 下载 25–30 实验包,包含固定依赖配置、完整源码与 Maven Wrapper。
系列起点:可复现基线。


