写古典密码工具时容易踩的三个坑

凯撒、维吉尼亚、Atbash 看起来都是「查表移位」,但大小写、非字母字符和密钥对齐这三处一旦处理错,结果会静默地不对。

站上的密码工具支持凯撒、ROT13、Atbash 和维吉尼亚四种。它们都是教科书级别的算法,写起来不到百行,但有三处细节处理错了不会报错,只会给出看起来像那么回事的错误结果。

Atbash 的字母镜像表,以及实心格代表参与移位、虚线格代表原样透传的分区示意

图:所有状态只在实心格里推进,虚线格(空格、数字、中文)原样透传。

凯撒:位移要归一化,非字母要原样保留

最直接的写法是对每个字符的编码加位移:

text.split('').map((ch) => String.fromCharCode(ch.charCodeAt(0) + shift))

这段代码有两个问题。第一,位移为负或大于 26 时,会跑到标点区去;第二,空格和标点被一起移走了。

正确做法是先归一化,再只在字母区间内循环:

export function caesar(text: string, shift: number): string {
  const step = ((shift % 26) + 26) % 26;
  return text.replace(/[a-z]/gi, (ch) => {
    const base = ch >= 'a' ? 97 : 65;
    return String.fromCharCode(((ch.charCodeAt(0) - base + step) % 26) + base);
  });
}

replace 的正则只匹配字母,非字母自然原样保留。大小写各自维持——解密时也要能还原回原样。

维吉尼亚:密钥只跳过非字母,不是跳过位置

维吉尼亚的错误版本长这样:

for (let i = 0; i < text.length; i += 1) {
  shift = key.charCodeAt(i % key.length) - 65;
  out += shiftChar(text[i], shift);
}

问题在于 i 同时代表「原文位置」和「密钥位置」。原文里有空格时,密钥也会跟着往前跳一格,于是后面全部错位。

正确做法是维护一个只在字母上递增的密钥游标:

let cursor = 0;
for (const ch of text) {
  if (!/[a-z]/i.test(ch)) {
    out += ch;
    continue;
  }
  const shift = key.charCodeAt(cursor % key.length) - 65;
  out += shiftChar(ch, shift);
  cursor += 1;
}

这也是手算维吉尼亚时的规则:密钥只跟着字母走。

Atbash:它只对拉丁字母成立

Atbash 是「A 对 Z、B 对 Y」的镜像替换。看起来和凯撒一样是字符映射,但它的前提是字母表长度固定且对称。

如果实现成 String.fromCharCode(219 - code),遇到中文、数字或标点就会得到乱码。正确做法仍然是只匹配 [a-z]:

export function atbash(text: string): string {
  return text.replace(/[a-z]/gi, (ch) => {
    const base = ch >= 'a' ? 97 : 65;
    return String.fromCharCode(base + 25 - (ch.charCodeAt(0) - base));
  });
}

中文原样保留——这不是缺陷,而是这个密码的定义域本来就只有拉丁字母。

共同的教训

三个坑本质上是同一件事:这些算法定义在字母表上,而不是定义在字符串上。

所以实现时统一遵守两条:

  • 用正则 [a-z] 圈定作用域,圈外原样透传
  • 所有状态(位移、密钥游标)只在圈内推进

工具页上还留了一行提示:古典密码只适合教学和谜题,不要用于任何真实加密。这句话比实现本身更重要。

← 返回文章列表

评论

…