申请会员 ID:birdyee
申 请 ID:birdyee
个人邮箱:cloudy2791@gmail.com
仓库:https://github.com/cloudy2791-1/Birdy-File-Steganography-2/

原创技术文章:
【原创】纯前端离线文件隐写工具 Birdy —— PNG zTXt块 + 三引擎LSB + AES-256-GCM 完整实现记录
一、为什么做这个东西
想把文件藏进图片发给朋友,我找了一圈现有工具:OpenStego 要装 Java,跨平台体验差;SilentEye 已停止维护,Win11 跑不起来;在线工具要把文件上传到服务器,这对我来说完全不可接受。
目标只有一个:单 HTML 文件,浏览器双击即用,全程本地运算,零上传,连网络请求都不发一条。最终写出来约 7000 行,塞进了三套完全独立的引擎。
二、整体架构与代码组织
工具有三个主要模式,各自由独立的类实现。
模式一(加密隐写 / 解密提取): 核心逻辑由 VaultWorker 类承载,运行在通过 Blob URL 创建的真正的 Web Worker 线程里,避免大文件加密时冻结页面。支持多文件同时加密打包。PNG 载体用 zTXt 元数据块嵌入,JPEG 载体用尾部追加,两种路径共享同一套 AES-256-GCM 加密流程。
模式二(LSB 高级隐写): 由四个类组成——LSBEngine(基础像素读写库)、AdaptiveLSBEngine(自适应方差策略)、MultiVolumeEngine(旧版多卷引擎)、UnifiedStegoEngine(统一调度层)。单图自动在自适应和标准 LSB 之间选策略,多图支持顺序分卷和散布模式两种分发方式。
整个项目只有一个 HTML 文件,没有构建步骤,没有 npm 依赖,浏览器双击就能用。
三、模式一:PNG zTXt 块隐写(完全不改像素)
这是和市面上大多数隐写工具最大的不同点:加密隐写模式完全不修改像素,把数据藏进 PNG 文件自带的元数据块里。
--- 3.1 PNG 文件结构
PNG 文件由连续的"块(Chunk)"组成,每块格式固定:
[长度: 4B uint32][类型: 4字节ASCII][数据: 若干字节][CRC32: 4B]
完整文件结构:IHDR → 若干 IDAT → (辅助块)→ IEND。
PNG 规范允许在 IDAT 和 IEND 之间插入任意辅助块,图片渲染器完全忽略它们,只渲染像素。其中 zTXt(压缩文本块)是 PNG 规范内置的合法类型,专门用来存注释、版权等信息,格式为:
[关键字: UTF-8字符串][0x00 结束符][0x00 压缩方法=deflate][deflate压缩的内容]
正常 PNG 里 zTXt 用来存作者信息、版权声明之类,任何工具看到都不会觉得可疑。
--- 3.2 VaultWorker 跑在独立 Web Worker 线程里
模式一的全部加密解密逻辑写在 VaultWorker 类里,这个类的代码作为字符串(WORKER_CODE)存储,运行时通过 Blob URL 动态创建 Web Worker:
const blob = new Blob([WORKER_CODE], { type: 'application/javascript' });
this.worker = new Worker(URL.createObjectURL(blob));
这样加密过程在独立线程里执行,几百 MB 的大文件也不会冻结界面。Worker 通过 postMessage 上报进度,主线程用 requestAnimationFrame 更新进度条,UI 完全不卡。
--- 3.3 多文件支持:MiniZip 打包成 Birdy Bundle
模式一支持同时加密多个文件。选了多个文件时,先用内置的 MiniZip 类将它们打包成一个 ZIP,再整体加密。这个 ZIP 格式是标准的,但会在所有文件前面插入一个哨兵条目:
SENTINEL_NAME = '\x00.birdy_bundle\x00'
SENTINEL_MAGIC = [0xB1, 0x4D, 0x2A, 0x01]
解密后,如果发现这个哨兵,就知道这是一个 Birdy bundle,会把 ZIP 内的各个文件分别列出来,用户可以逐个下载,而不是拿到一个看不出内容的 ZIP。
--- 3.4 加密数据的二进制格式
嵌入之前,数据经过 AES-256-GCM 加密,payload 的完整格式如下(所有多字节整数均 big-endian):
偏移 长度 内容
────────────────────────────────────────────────────
0 4B 总长度(含本字段本身)
4 4B MARKER 魔数:'bird'(0x62 0x69 0x72 0x64)
8 4B PBKDF2 迭代次数
12 16B Salt(随机生成)
28 12B 元数据加密 IV(ivMeta,随机生成)
40 4B 元数据密文长度
44 变长 元数据密文(AES-GCM 加密的 JSON 字符串)
44+N 12B 文件数据加密 IV(ivFile,随机生成)
56+N 4B 文件数据密文总长度
60+N 变长 文件数据密文(1MB 分块加密后拼接)
元数据是一段 JSON,包含原始文件名、文件大小、MIME 类型、加密时间戳、迭代次数,以及原始文件的 SHA-256 哈希值(base64 编码)。这个哈希在解密后用于完整性验证:解密完重新算一遍,和元数据里的对上才算通过,有一个字节被改过都会被发现。
元数据和文件数据各自独立加密,各用一个随机 IV,共用同一个密钥(由同一个 salt+密码派生)。两者的 AAD(附加认证数据)分别为 concat(salt, ivMeta) 和 concat(salt, ivFile)。这样即使文件数据泄露也不影响元数据的安全性,也防止了把密文从这张图片移到另一张图片里去的攻击——换了 salt 或 IV,GCM 认证必然失败。
--- 3.5 密钥派生与分块加密
密钥通过 PBKDF2 从密码派生,模式一提供三档迭代次数:25万次(标准/快速)、50万次(增强强度,默认推荐)、100万次(极限强度,慢但最抗暴力破解)。
async deriveKey(password, salt, iterations) {
const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false, ['encrypt', 'decrypt']
);
}
文件内容按 1MB 一块加密,每块的 IV 由基础 ivFile 与块序号 XOR 得到,防止多块复用相同密钥流:
const chunkIv = new Uint8Array(ivFile); // 复制基础IV
const indexBytes = writeUint32(i); // 当前块序号
for (let j = 0; j < 4; j++) chunkIv[j] ^= indexBytes[j];
const encryptedChunk = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: chunkIv, additionalData: aadFile, tagLength: 128 },
key, chunk
);
GCM 模式自带 16 字节认证标签,解密时如果密文有任何一个字节被篡改,crypto.subtle.decrypt 直接抛 OperationError,不会返回乱码数据。
--- 3.6 PNG zTXt 块嵌入细节
解析原始 PNG 的块结构,把 IEND 取出来留着,然后把密文按最大 65536 字节切片,每片包成一个 zTXt 块,插入 IEND 之前。
每次嵌入操作生成一个 16 字节随机 sessionId,属于本次嵌入的所有块都携带这个 ID(放在 deflate 压缩前的数据头部里)。这样处理是为了应对同一张图片被多次嵌入的情况——提取时按 sessionId 分组,取出现次数最多的那组(最后一次嵌入),按序号排序后还原数据。
每个 zTXt 块的关键字从 15 个常见的 PNG 注释词里随机选一个(Comment、Author、Description、Software 等),让它看上去像正常的图片元数据。
这里的压缩用的是 CompressionStream 的 deflate 模式(zlib 格式,RFC 1950,带 2 字节 zlib 头和 Adler-32 校验尾),符合 PNG 规范对 zTXt 块的要求。
--- 3.7 JPEG 尾部追加模式
JPEG 没有 PNG 那样的辅助块机制,改为直接在文件末尾追加数据。这种模式默认是禁用的——默认情况下选了 JPEG 封面,工具会先把它转成 PNG 再嵌入。只有显式勾选"保持 JPEG 格式"才启用尾部追加,界面同时弹出红色高风险警告。
原因是:任何图片编辑器重新保存 JPEG 时都会重新做 DCT 变换,JPEG 尾部被截断,数据永久丢失。
提取 JPEG 数据时,从文件末尾往前扫描找 "bird" 标记(4字节),找到后用长度字段做二次验证(起始位置 + 长度 == 文件总大小),防止文件内容中碰巧含有相同字节序列导致误判。
--- 3.8 下载链接与内存安全
加密或解密完成后,下载链接开始 30 秒倒计时,到期自动调用 URL.revokeObjectURL() 释放内存,链接失效。用户也可以点"立即销毁"手动触发。
密码输入框点"显示"查看密码后,15 秒自动切回隐藏模式。随机生成的密码在密码显示区展示,30 秒后自动隐藏——隐藏前先对显示文本写入随机字节再清空,不直接置空。
页面有"销毁痕迹"按钮,点击后清空所有表单、终止 Worker、吊销所有 Blob URL、重置模式二的 UnifiedUI 状态。
四、LSBEngine:像素操作基础库
所有 LSB 读写最终都经过这个类,是全工具的底层基础。
--- 4.1 通道映射:如何跳过 Alpha
Canvas 的 ImageData.data 是 RGBA 交错排列的平铺数组:[R0, G0, B0, A0, R1, G1, B1, A1, ...]。代码用一个连续的"通道索引 ch"来访问 RGB 通道,自然绕过 Alpha:
// ch 是跳过 Alpha 后的连续通道号
// ch=0→第0像素R, ch=1→G, ch=2→B, ch=3→第1像素R, ...
const pixelIdx = Math.floor(ch / 3); // 第几个像素
const byteIdx = pixelIdx * 4 + (ch % 3); // 在RGBA数组里的偏移(永远不会是Alpha=3)
这样 Alpha 通道永远不会被触碰,避免透明区域相关的统计检测问题。
--- 4.2 三种位深度的编码
UI 对外只暴露 1位/通道 和 2位/通道 两个选项,引擎内部还支持 4位(供自适应模式的头部写入使用)。1位模式有特殊的边界处理:
if ((curr & 1) !== desired) {
if (curr === 0) pixels[byteIdx] = 1; // 避免 0-1 溢出
else if (curr === 255) pixels[byteIdx] = 254; // 避免 255+1 溢出
else if ((ch & 1) === 1) pixels[byteIdx] = curr + 1; // 奇数通道向上
else pixels[byteIdx] = curr - 1; // 偶数通道向下
}
对边界值 0 和 255 做特殊处理避免溢出截断;对其余值根据通道索引奇偶性决定 +1 还是 -1,防止所有修改都往同一个方向偏,避免统计上的可检测偏差。2位模式清最低2位再写新值(& 0xFC | data),4位模式清最低4位(& 0xF0 | data),不需要边界特殊处理。
--- 4.3 标准 LSB 的头部与 payload 格式
魔数是 "LSBF"(0x4C 0x53 0x42 0x46),头部 9 字节写在像素通道的最前面:
[MAGIC "LSBF": 4B] 写入前先 XOR stableMask 混淆
[flags: 1B] bit0=是否加密 bit1=是否预压缩
[payloadLen: 4B] big-endian uint32
[payload: 若干字节]
payload 本身的结构:
[文件名长度: 1B] 最多255字节UTF-8,超过截断
[文件名: UTF-8]
[文件内容: 原始字节]
如果有密码,整个 payload 先经 AES-256-GCM 加密再写入。这里的加密(LSBEngine.aesEncrypt)是固定 10 万次 PBKDF2 迭代的旧格式,salt 16B + iv 12B 直接拼在密文头部。模式二通过 UnifiedStegoEngine 调用时,改用可配置迭代次数的 aesEncryptEx(见后文)。
净容量公式(去掉 9 字节头部后可用于 payload 的字节数):
capacity(w, h, bitsPerCh) = Math.floor(w * h * 3 * bitsPerCh / 8) - 9
以 1920×1080 图片、1位/通道为例:floor(1920×1080×3×1/8) - 9 = 777,591 字节 ≈ 759 KB。
--- 4.4 _injectNoise:填充剩余通道(仅标准 LSB 模式)
标准 LSB 写完数据后,剩余未用的像素通道不保留原值,而是用 PRNG 填入伪随机位。原因:如果数据区之后 LSB 明显不同于数据区,统计工具可以找出"数据末尾"的位置,暴露隐写范围。填充噪声后,从数据区到图片末尾 LSB 分布看起来均匀,没有明显边界。
注意:这个步骤只在标准 LSB 模式调用,自适应模式不调用——自适应模式本就只修改高方差像素,剩余像素完全不动,不需要额外填充。
PRNG 用的是 SFC32(Small Fast Counting,简单高速的32位伪随机生成器),种子来自 stableMask(见下节)与已用通道数的组合,预热 16 步:
static _sfc32(a, b, c, d) {
return function() {
a |= 0; b |= 0; c |= 0; d |= 0;
const t = (a + b | 0) + d | 0;
d = d + 1 | 0;
a = b ^ (b >>> 9);
b = c + (c << 3) | 0;
c = (c << 21) | (c >>> 11);
c = c + t | 0;
return t >>> 0;
};
}
--- 4.5 _computeStableMask:基于图片宽高的魔数混淆
这是一个容易被忽视的隐蔽设计。函数签名是 _computeStableMask(pixels, width, height),但 pixels 参数根本没被使用——它只根据图片的宽度和高度,用类 FNV-1a 哈希算法派生出 4 字节掩码:
static _computeStableMask(pixels, width, height) {
const w = (width >>> 0) & 0xFFFF;
const h = (height >>> 0) & 0xFFFF;
let h0 = 0x811c9dc5, h1 = 0x91e1f2a3,
h2 = 0xc4a637b5, h3 = 0x5e3d2f01;
h0 = Math.imul(h0 ^ ((w>>>8)&0xFF), 0x01000193) >>> 0;
h0 = Math.imul(h0 ^ (w&0xFF), 0x01000193) >>> 0;
h1 = Math.imul(h1 ^ ((h>>>8)&0xFF), 0x01000193) >>> 0;
h1 = Math.imul(h1 ^ (h&0xFF), 0x01000193) >>> 0;
const cross = ((w*31 + h*17) ^ (w^h)) & 0xFFFF;
h2 = Math.imul(h2 ^ ((cross>>>8)&0xFF), 0x01000193) >>> 0;
h2 = Math.imul(h2 ^ (cross&0xFF), 0x01000193) >>> 0;
h3 = (h0 ^ h1 ^ h2) >>> 0;
return new Uint8Array([
(h0^h3)&0xFF, (h1^(h3>>>8))&0xFF,
(h2^(h3>>>16))&0xFF, ((h0>>>8)^(h1>>>16)^(h2>>>24))&0xFF
]);
}
嵌入时,调用方(而不是底层函数本身)用这个掩码 XOR 头部的前 4 字节(魔数部分):
hdr[0] ^= mask[0]; hdr[1] ^= mask[1];
hdr[2] ^= mask[2]; hdr[3] ^= mask[3];
提取时,读出头部后再 XOR 同一个掩码还原,检测条件变成 (h ^ mask) === 期望魔数。效果:1920×1080 和 1280×720 的图片,头部字节完全不同。静态扫描工具无法用固定字节序列定位本工具生成的隐写数据。
五、AdaptiveLSBEngine:基于方差的自适应隐写
自适应模式不是在所有像素上写数据,而是只选用"纹理复杂"的像素区域,在容量和视觉质量之间取得平衡。
--- 5.1 局部方差图计算
对每个像素,计算以它为中心、半径为 2 的 5×5 邻域内的亮度方差(Rec.601 加权亮度:0.299R + 0.587G + 0.114B)。方差越大,说明该区域颜色变化越剧烈,人眼在这里更难察觉像素改动。
计算方差时,对像素亮度做位掩码 bm 的屏蔽,去掉已被嵌入修改的最低若干位:
// 屏蔽已被修改的最低位,模拟"未嵌入时"的亮度分布
const bm = bitsPerCh === 4 ? 0xF0 : bitsPerCh === 2 ? 0xFC : 0xFE;
const lum = (pixels[base] & bm) * 0.299
+ (pixels[base+1] & bm) * 0.587
+ (pixels[base+2] & bm) * 0.114;
map[y*w+x] = Math.max(0, sum2/cnt - mean*mean); // E[X2] - E[X]2
为什么提取时也要做这个屏蔽:提取时像素已经被修改了,如果直接用修改后的值算方差,结果和嵌入时不一样,重建出的掩码会偏移,所有数据读出来都是乱码。屏蔽最低位就是在模拟"未修改前"的亮度分布,让两次方差图严格一致。
--- 5.2 GUARD_FRACTION 安全余量
用户通过滑块设定"使用前 N% 的高方差像素"(topPct),但实际使用的是 topPct × 0.80,保留 20% 余量:
const effPct = Math.min(topPct * 0.80, 0.99);
const srtDesc = new Float32Array(varMap).sort((a, b) => b - a); // 降序排列
const thrIdx = Math.min(N-1, Math.floor(N * effPct));
const absThr = srtDesc[thrIdx]; // 绝对方差阈值(float32)
留 20% 余量的原因:图片边界像素方差会随嵌入内容略有波动,用满 100% 的话,少数边界像素可能嵌入时在掩码里、提取时不在,导致掩码不稳定、数据读错位。
--- 5.3 将 float32 阈值存入头部
为了让提取时精确重建同一张掩码,要把 absThr 存入头部。直接存浮点数有精度问题,代码用"按位重解释"把 float32 转成 uint32 再存储:
static _f32ToU32(f) {
const b = new ArrayBuffer(4);
new Float32Array(b)[0] = f;
return new Uint32Array(b)[0]; // 同一内存,不同类型读取
}
static _u32ToF32(u) {
const b = new ArrayBuffer(4);
new Uint32Array(b)[0] = u;
return new Float32Array(b)[0];
}
--- 5.4 自适应 LSB 头部格式(14 字节)
魔数是 "ADPF"(0x41 0x44 0x50 0x46),头部写入前 64 个像素(HDR_RESERVE = 64)的 RGB 通道里,这 64 个像素无论方差多高都从掩码中排除:
[MAGIC "ADPF": 4B] 写入前先 XOR stableMask 混淆
[flags: 1B] bit0=加密 bit1=压缩
[bitsPerCh: 1B] 实际使用的位深(1 / 2 / 4)
[absThr as uint32: 4B] float32 的按位重解释,big-endian
[payloadLen: 4B] big-endian uint32
六、UnifiedStegoEngine:模式二的统一调度层
--- 6.1 单图策略自动选择
单图嵌入时,优先自适应;自适应容量不够再退到标准 LSB;两者都不够才报错:
if (payload.length <= adaptiveCap) {
strategy = 'adaptive';
await this._embedPayloadAdaptive(imgData, payload, flags, bitsPerCh, topPct, cb);
} else if (payload.length <= lsbCap) {
strategy = 'lsb';
await this._embedPayloadLSB(imgData, payload, flags, bitsPerCh, cb);
} else {
throw new Error('图片容量不足,请选更大图片或使用多图分卷');
}
这里的 payload 是加密和 CRC32 追加之后的结果,比的是已加密数据大小和图片容量。
--- 6.2 aesEncryptEx:迭代次数可配置,存进密文头部
模式二的加密用 aesEncryptEx,不是 LSBEngine.aesEncrypt(后者固定 10 万次)。aesEncryptEx 把迭代次数存在输出的前 4 字节,解密时自动读取:
// 输出格式: [iterations 4B big-endian][salt 16B][iv 12B][AES-GCM密文]
const result = new Uint8Array(4 + 16 + 12 + ct.byteLength);
new DataView(result.buffer).setUint32(0, iterations, false); // 迭代次数存头部
result.set(salt, 4);
result.set(iv, 20);
result.set(new Uint8Array(ct), 32);
解密时(aesDecryptAuto)先读头部 4 字节,若在合法范围(10000~2000000)内则按新格式解密,否则回退到旧格式(无迭代次数头部),保证向下兼容。模式二的四档迭代次数:5万(快速)、20万(标准,默认)、50万(安全)、100万(极安全)。
--- 6.3 flags 字节的三个标志位
bit0 (0x01): 是否加密
bit1 (0x02): 是否预压缩(deflate-raw,RFC 1951 原始 DEFLATE,无 zlib 包头)
bit2 (0x04): 是否散布模式(多图时有效)
预压缩和模式一 VaultWorker 的 deflate(zlib 格式,RFC 1950)不同,这里用的是 deflate-raw(原始 DEFLATE,无2字节 zlib 头和 Adler-32 尾)。只有压缩后体积缩小超过 2% 才实际采用,否则跳过。
--- 6.4 CRC32 完整性校验
加密前把 CRC32(4字节)追加到 payload 末尾;解密后重新计算,不匹配直接报错,精确区分"密码错误"和"数据损坏"两种情况:
static _appendCrc(data) {
const crc = this.crc32(data);
const result = new Uint8Array(data.length + 4);
result.set(data);
new DataView(result.buffer, data.length, 4).setUint32(0, crc, false);
return result;
}
七、多图分卷:MVLB 格式与散布模式
--- 7.1 分卷头部格式(49 字节)
多图分卷的魔数是 "MVLB"(0x4D 0x56 0x4C 0x42),头部由调用方 XOR stableMask 混淆后写入:
[MAGIC "MVLB": 4B] 写入前 XOR stableMask
[卷号 volIdx: 4B] 0-based,big-endian
[总卷数 totalVols: 4B] big-endian
[本卷数据长度 chunkLen: 4B] big-endian
[flags: 1B] bit0=加密 bit1=压缩 bit2=散布
[本卷数据的 SHA-256: 32B] 提取时逐卷验证
MultiVolumeEngine.HDR_SIZE = 49,容量计算时要先减去这 49 字节。每卷存储本卷 chunk 的完整 SHA-256 哈希(用 crypto.subtle.digest 计算),提取时逐卷验证,任何一卷不匹配就精确报告是哪一卷损坏。
--- 7.2 按容量比例分配数据
多图时不是平均分,而是按各图片的实际可用容量比例分配字节数,舍入误差按容量从大到小补足:
const allocs = caps.map(c => Math.floor((c / totalCap) * payload.length));
--- 7.3 散布模式(Scatter Mode)
顺序分卷把连续的数据片段放进各图;散布模式打乱 payload 字节的分发顺序,每张图携带的是原始数据中随机位置的字节,而不是连续片段。
第一步,用 SHA-256 派生种子,输入是字符串 "birdy-scatter-v1|" + 密码 + payload 长度(4字节)的拼接,不同密码和不同长度产生完全不同的置换。
第二步,用 SHA-256 结果的前 16 字节初始化 SFC32,预热 20 步。
第三步,Fisher-Yates 洗牌,对 [0, payloadLen-1] 的初始数组做洗牌,得到置换数组 posArr。洗牌时用拒绝采样消除模运算的统计偏差:
for (let i = payloadLen - 1; i > 0; i--) {
const range = i + 1;
const threshold = (0x100000000 % range) >>> 0; // 消除偏差
let r;
do { r = rand() >>> 0; } while (r < threshold); // 拒绝采样
const j = r % range;
const tmp = posArr; posArr = posArr[j]; posArr[j] = tmp;
}
嵌入时:chunk[k] = payload[posArr[rndPos + k]],取置换位置上的字节写入当前卷。
提取时,各卷拼合后,用相同密码重建同样的 posArr,执行逆置换:
const posArr = await UnifiedStegoEngine._buildScatterPerm(full.length, password);
const original = new Uint8Array(full.length);
for (let k = 0; k < full.length; k++) original[posArr[k]] = full[k];
// original[posArr[k]] = full[k] 等效于 posArr 的逆置换
为什么用拒绝采样:rand() % range 直接取模,当 0x100000000 % range ≠ 0 时,较小的余数出现概率略高,产生可统计的偏差。拒绝采样丢弃落在 [0, threshold) 区间的值重新生成,使每个 j 值的概率严格相等,代价是偶尔多采样一次(期望额外采样次数远小于1)。
--- 7.4 提取时的批次冲突检测
多图提取支持精确的混合批次检测:按 totalVols(总卷数字段)对所有上传图片分组,如果出现多个不同的 totalVols 值,说明来自不同批次,报错并逐一列出每个批次包含哪些文件名;如果同一卷号出现多次,单独报错列出重复文件名;缺少某卷也会精确指出缺第几卷。
八、安全与隐私设计细节
--- wipeBuffer:多步内存清除
crypto.getRandomValues(view); // 随机字节覆盖原值
view.fill(0xAA); // 写已知模式,破坏可预测性
crypto.getRandomValues(view); // 再次随机覆盖
view.fill(0); // 最终清零
四步操作比直接 fill(0) 更难被内存分析工具恢复。主要针对 ArrayBuffer/Uint8Array 形式的二进制数据;JavaScript 字符串不可变,对字符串形式的密码无法做到完全擦除(V8 可能内部化字符串)。
--- 密码强度检测
内置 100+ 条常见弱密码黑名单(Set 结构,O(1) 查询),加上 28 种键盘连续模式检测(qwerty、1qaz2wsx、asdfghjkl 等),4+ 重复字符检测,纯年份检测(1900~2099)。命中任一条件,评分直接归零,界面显示红色强度条拦截。
--- 法律声明弹窗
页面首次加载时强制弹出完整免责声明,"同意并继续使用"按钮默认禁用,必须将声明下滑读完(滚动到距底部 2px 以内)才能点击。这是真实的阅读强制机制,不是摆设。
九、踩过的主要坑
坑1:自适应模式提取时方差图不一致,数据全是乱码
提取时像素已被修改,如果直接用修改后的像素算方差,结果和嵌入时的不同,掩码会整体偏移。解决:计算方差时用 bm 掩码屏蔽已被修改的最低若干位,模拟未修改时的亮度分布。bm 值从头部读出 bitsPerCh 后才能确定,必须和嵌入时一致。
坑2:JPEG 尾部扫描误判
最初从头往后找 "bird" 标记,但文件内容里随机出现这 4 个字节完全可能。改为从末尾往前扫描,找到候选位置后再做二次验证(起始位置 + 长度字段 == 文件总长),双重确认才认定有效。
坑3:Fisher-Yates 洗牌有统计偏差
rand() % range 直接取模,当 0x100000000 % range ≠ 0 时,较小的余数出现频率略高。数据量大时产生可测量的不均匀分布。引入拒绝采样后严格均匀。
坑4:Canvas 大图卡死主线程
2000×2000 以上的图片逐像素操作轻松卡住页面 5 秒以上。在大循环里每隔一定量调用 await new Promise(r => setTimeout(r, 0)) 把控制权还给浏览器(_yieldMain 方法),进度条才能正常刷新。
坑5:createImageBitmap 在 Safari 上报错
Safari 早期版本对某些 JPEG 子格式的 createImageBitmap 会直接抛异常。写了 _createBitmapSafe 方法,先尝试 createImageBitmap,失败则回退到 new Image() + Canvas 方式,两路都加了超时保护(20秒/30秒)。
坑6:多文件解密后没有展开各文件
最初解密完直接给用户一个 ZIP 文件下载,没有考虑这是 Birdy bundle。后来加了 MiniZip.read 检测:先判断开头是否是 PK 签名,再读哨兵条目,确认是 bundle 才展示各文件的单独下载列表;否则当普通文件处理。
十、整体数据流简图
模式一(加密):
[多文件] → MiniZip.build → [bundle.zip]
│
[单文件] ──────────────────────┘
│
JPEG封面 → canvas.toBlob("image/png") 先转为PNG
│
PBKDF2(密码, salt, 25/50/100万次) → AES-256 key
│
encrypt(元数据JSON) + encrypt(文件数据,1MB分块,每块独立IV)
│
[4B总长]['bird'][4B迭代次数][16B salt][12B ivMeta]
[4B metaLen][元数据密文][12B ivFile][4B encLen][文件密文]
│
PNG封面 → zTXt块嵌入(多片,各带16B sessionId + 4B序号)
JPEG封面(显式勾选)→ 文件尾追加
│
下载链接(30秒倒计时后自动销毁)
模式二(LSB单图):
[秘密文件] → (可选 deflate-raw 预压缩,体积缩减>2%才采用)
→ buildPayload(1B文件名长 + 文件名UTF-8 + 内容)
→ (可选 CRC32 追加 4B)
→ aesEncryptEx(密码, payload, 5/20/50/100万次)
输出: [4B迭代次数][16B salt][12B iv][GCM密文]
│
[图片] → getImageData → 计算方差图
│
自适应容量够? → _embedPayloadAdaptive(14B头部 + 方差掩码选像素)
否则LSB? → _embedPayloadLSB + _injectNoise(随机填充剩余通道)
│
头部前4字节 XOR stableMask(width, height) 混淆
│
putImageData → canvas.toBlob("image/png") → 下载
工具在 Chrome、Firefox、Edge、Safari(含 iOS)及 Android 主流浏览器上测试通过,CompressionStream 需要 Chrome 80+ / Edge 80+ / Firefox 110+,老浏览器只能用 JPEG 尾部追加模式。有问题欢迎在帖子下方交流,感谢大佬审核!
|