3D模型加密链路通常包含序列化、加密、编码三个步骤。序列化把场景树、网格、材质、动画通道整理成glTF JSON或GLB二进制;加密用AES等对称算法把明文字节转成密文;编码则把密文字节映射成文本,常见的是Base64。解密端需要严格反向执行:文本编码还原成密文字节,AES解密成明文字节,再交给glTF或GLB解析器。链路越长,越容易在编码与填充两个看似简单的位置出现偏差。实际项目中,Bad padding exception、Unexpected end of JSON input、chunk length overflow等错误,九成与这两个环节有关。

一、编码层最容易出现三类偏差
加密得到的是二进制密文,通常需要转换成文本进行传输和存储。Base64是最常用的编码方案,但Base64并非只有一种写法。标准Base64使用加号和斜杠作为第62、63个字符,URL安全Base64则使用减号和下划线,以避免URL路径和查询参数里的特殊字符。很多3D模型下载接口会把密文放在URL参数中,前端为了安全使用URL安全Base64,后端却按标准Base64解码,结果就是解码出的字节序列偏移或长度变化,AES解密直接失败。反过来,如果前端接收后端返回的标准Base64,却先做了URL安全替换,也会把原本正确的加号或斜杠破坏掉。
第二个常见问题是Data URI前缀。加密后的模型可能被包成data:application/octet-stream;base64,xxxx这种形式。解码前如果没有剥离前缀,Base64解码器会把data、冒号、分号这些字符也纳入解码范围。有些解码器会静默丢弃非法字符,有些则直接抛错,还有一部分会得到错误字节。正确做法是先定位第一个逗号,再处理逗号后面的Base64主体,同时去除所有换行、回车和空格。
function decodeBase64(input) {
let source = input.trim();
const comma = source.indexOf(',');
if (comma !== -1) {
source = source.slice(comma + 1);
}
source = source.replace(/-/g, '+').replace(/_/g, '/');
while (source.length % 4 !== 0) {
source += '=';
}
const raw = atob(source);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) {
bytes[i] = raw.charCodeAt(i);
}
return bytes;
}
第三个编码偏差是十六进制与二进制字符串的混淆。有些后端把密文转成十六进制字符串再存入数据库,解密端如果直接把该字符串按UTF-8编码交给AES,等于把每个字节变成了两个字节,AES看到的密文长度翻倍,必然报填充错误或长度错误。必须先按十六进制还原成原始字节,再进入解密流程。排查时只要打印Base64解码后长度和AES要求的块大小,通常就能发现这类长度异常。
二、填充不是简单的去掉末尾几个字节
AES的CBC和ECB模式要求明文长度必须是16字节的整数倍。加密前的glTF JSON或GLB二进制很难刚好满足这个条件,因此需要填充。最常见的是PKCS#7填充,它会在明文末尾追加n个值为n的字节,n等于16减去当前长度对16取模后的余数。解密完成后,最后一个字节的值就表示需要去掉多少填充。但解密后的数据尾部可能恰好出现一个看起来合理的数字,如果不校验所有填充字节是否都等于该数字,就可能误删真实数据。
def remove_pkcs7_padding(data: bytes, block_size: int = 16) -> bytes:
if not data or len(data) % block_size != 0:
raise ValueError("decrypted data length is not block aligned")
pad_len = data[-1]
if pad_len < 1 or pad_len > block_size:
raise ValueError("invalid padding length")
if data[-pad_len:] != bytes([pad_len]) * pad_len:
raise ValueError("invalid padding bytes")
return data[:-pad_len]
Zero Padding是另一种常见策略,它在明文末尾追加0x00字节直到块大小对齐。这种方式的缺陷是无法区分真正的0x00字节和填充字节,因此不适合加密二进制数据。GLB模型本身就包含大量0x00字节,尤其是BIN数据块末尾或访问器对齐区域。如果解密端错误地使用Zero Padding策略,很可能把GLB里真实的0x00字节一并去掉,导致GLB头部的length字段不再等于实际数据长度,解析器会报chunk length溢出或文件截断。
还有一种错误是解密后完全不做去填充。比如加密端使用PKCS#7,解密端却把AES输出直接交给JSON解析器。此时glTF JSON末尾会多出0x10、0x0F这类不可见控制字符,JSON解析器可能报错,也可能静默接受但后续访问器偏移计算异常。正确的做法是使用上述校验函数去除填充,再把结果传给JSON.parse或GLB解析器。需要强调的是,去填充操作必须在AES解密之后、业务解析之前进行,顺序不能颠倒。
三、GLB对齐填充与加密填充不能混为一谈
GLB是glTF的二进制容器,文件头部12字节保存魔数、版本和总长度,之后是JSON chunk和BIN chunk。每个chunk的chunkLength字段必须是4字节的倍数,如果原始JSON或二进制长度不足,GLB写入器会追加空格0x20或0x00字节进行对齐。这种对齐填充属于GLB格式自身的结构要求,是明文字节的一部分,应该在加密前保留、解密后原样交给GLB解析器。
实际排查中容易出现问题的情况是,开发者看到解密后的GLB尾部有一串0x00,误以为那是加密填充,用Zero Padding逻辑删掉。GLB解析器读取时,头部length字段仍然指向原始总长度,但实际数据已经被截短,于是读取到文件末尾后越界。相反,如果GLB文件在加密前本身已经按4字节对齐,加密端又追加了PKCS#7填充,解密后这些PKCS#7填充必须去除,否则GLB头部length与末尾数据又不一致。判断依据不是尾部是否为0,而是加密阶段采用的填充策略。
更稳妥的做法是在加密前明确记录三个信息:是否使用PKCS#7、是否使用Base64、Base64是否为URL安全变体。这些元数据可以单独放在响应头或协议字段里,不要靠猜测。解密端根据元数据选择对应的解码和去填充逻辑,能避免大部分因实现细节不一致导致的模型加载失败。
四、用字节长度和尾部十六进制快速定位问题
遇到3D模型解密错误时,不要急着怀疑密钥。先检查密文进入AES前的字节长度是否是16的整数倍。如果使用Base64,解码后的长度除以16余数不为零,说明Base64解码本身就有问题,可能是前缀未剥离、URL安全字符未替换、或者密文在传输中被截断。第二项检查是AES解密后的尾部字节。使用十六进制工具查看最后32个字节,如果看到0x10连续出现10次,说明这是PKCS#7填充的10字节,应该去掉;如果看到一串0x00,则需要结合加密策略判断是Zero Padding还是GLB对齐填充。
xxd -l 32 -g 1 decrypted_model.bin
在JavaScript端,可以用Uint8Array打印尾部内容;在Node.js后端,可以用Buffer的toString方法把字节转成十六进制。例如将解密结果存入modelBuffer,然后执行modelBuffer.slice(-32).toString('hex'),就能看到末尾的十六进制序列。将这些值与预期填充模式比对,通常几秒钟就能确定问题出在编码层还是填充层。如果尾部是0x10重复,但JSON解析仍失败,再去检查去填充后的首部是否有魔数、BOM或换行等不可见字符。
编码与填充问题之所以常见,是因为它们处于加密链路的外围,常常被当作简单的字符串处理。但3D模型文件动辄数十MB,又混合JSON文本与二进制数据,任何一个字节的缺失或多余都会沿着解析链放大为渲染崩溃或场景加载失败。把Base64变体、Data URI前缀、去填充校验、GLB对齐这四件事固定成统一的解密工具函数,能显著降低模型加密传输系统里的隐蔽故障。