Шифр Плейфера на Python

Разберём реализацию шифра Плейфера на Python — с полноценной поддержкой кириллицы, а не только английского алфавита, как в большинстве учебных примеров. Код ниже полностью рабочий: его можно скопировать в файл и запустить без изменений.

Если механика шифра пока не очевидна, начните с разбора правил: как работает шифр Плейфера.

Что усложняет задачу на русском

Английская реализация умещается в тридцать строк: 26 букв, объединяем I и J, получаем квадрат 5 × 5. С русским алфавитом появляются три отличия, которые ломают наивный код:

  • таблица не квадратная — 4 × 8, поэтому число строк и столбцов надо хранить раздельно;
  • объединять приходится Е и Ё, и делать это нужно и в ключе, и в тексте;
  • буква-заполнитель другая: в английской традиции это X, в русской обычно Х — и это разные символы Unicode, хотя выглядят одинаково.

Последний пункт — источник трудноуловимых багов. Латинская X имеет код U+0058, кириллическая Х — U+0425. Визуально они неразличимы, но "X" == "Х" вернёт False.

Структура решения

Задача естественно распадается на четыре независимые функции:

  1. normalize — приводит текст к верхнему регистру, объединяет буквы и выбрасывает всё, чего нет в алфавите;
  2. build_table — строит сетку из ключа;
  3. to_bigrams — разбивает буквы на пары, вставляя заполнители;
  4. playfair — применяет три правила замены.

Такое разделение удобно тем, что каждую часть можно протестировать отдельно, а параметры обоих языков вынести в один словарь.

Полный код

# -*- coding: utf-8 -*-

RU_ALPHABET = "АБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ"  # 32 буквы: Ё объединена с Е
EN_ALPHABET = "ABCDEFGHIKLMNOPQRSTUVWXYZ"          # 25 букв: J объединена с I

# алфавит, число столбцов, основной заполнитель, запасной заполнитель
ALPHABETS = {
    "ru": (RU_ALPHABET, 8, "Х", "Ъ"),
    "en": (EN_ALPHABET, 5, "X", "Q"),
}


def normalize(text, lang):
    """Оставляет только буквы алфавита, объединяя Ё с Е (или J с I)."""
    alphabet, _, _, _ = ALPHABETS[lang]
    merged = {"Ё": "Е"} if lang == "ru" else {"J": "I"}
    result = []
    for char in text.upper():
        char = merged.get(char, char)
        if char in alphabet:
            result.append(char)
    return result


def build_table(key, lang):
    """Строит таблицу: сначала уникальные буквы ключа, затем остаток алфавита."""
    alphabet, cols, _, _ = ALPHABETS[lang]
    seen = []
    for char in normalize(key, lang):
        if char not in seen:
            seen.append(char)
    for char in alphabet:
        if char not in seen:
            seen.append(char)
    return [seen[i:i + cols] for i in range(0, len(seen), cols)]


def positions(table):
    """Словарь «буква -> (строка, столбец)» для быстрого поиска."""
    return {char: (r, c) for r, row in enumerate(table) for c, char in enumerate(row)}


def to_bigrams(letters, filler, alt_filler):
    """Разбивает буквы на пары, разделяя одинаковые буквы заполнителем."""
    pairs, i = [], 0
    while i < len(letters):
        a = letters[i]
        b = letters[i + 1] if i + 1 < len(letters) else None
        if b is None or a == b:
            b = alt_filler if a == filler else filler
            i += 1          # вторая буква уходит в следующую пару
        else:
            i += 2
        pairs.append((a, b))
    return pairs


def playfair(text, key, lang="ru", decrypt=False):
    """Шифрует или расшифровывает текст шифром Плейфера."""
    _, cols, filler, alt_filler = ALPHABETS[lang]
    table = build_table(key, lang)
    pos = positions(table)
    rows = len(table)
    shift = -1 if decrypt else 1

    out = []
    for a, b in to_bigrams(normalize(text, lang), filler, alt_filler):
        (r1, c1), (r2, c2) = pos[a], pos[b]
        if r1 == r2:                                    # одна строка
            out += [table[r1][(c1 + shift) % cols],
                    table[r2][(c2 + shift) % cols]]
        elif c1 == c2:                                  # один столбец
            out += [table[(r1 + shift) % rows][c1],
                    table[(r2 + shift) % rows][c2]]
        else:                                           # прямоугольник
            out += [table[r1][c2], table[r2][c1]]
    return "".join(out)

Проверяем на реальных данных

Построим таблицу для ключа КРИПТОГРАФИЯ:

for row in build_table("КРИПТОГРАФИЯ", "ru"):
    print(" ".join(row))
К Р И П Т О Г А Ф Я Б В Д Е Ж З Й Л М Н С У Х Ц Ч Ш Щ Ъ Ы Ь Э Ю

Теперь круговой тест — шифруем и сразу расшифровываем:

secret = playfair("Атака на рассвете", "КРИПТОГРАФИЯ")
print(secret)                                          # КОКРПЦКИТЦНДДОЖУ
print(playfair(secret, "КРИПТОГРАФИЯ", decrypt=True))   # АТАКАНАРАССВЕТЕХ

Последняя буква Х в расшифрованном тексте — служебный заполнитель: исходная фраза содержала нечётное число букв. Это нормальное поведение шифра, а не ошибка кода.

Классический английский тест

Любая реализация Плейфера должна проходить эталонный пример с ключом MONARCHY, который встречается во всех учебниках:

en = playfair("Hide the gold", "MONARCHY", lang="en")
print(en)                                        # BFCKPDFIMPBZ
print(playfair(en, "MONARCHY", lang="en", decrypt=True))   # HIDETHEGOLDX

Если у вас получилось BFCKPDFIMPBZ — правила замены реализованы верно. Расхождение обычно означает ошибку в правиле прямоугольника: буквы перепутаны местами.

Сверить результат работы своего кода с эталоном:

Шифр Плейфера онлайн

Три места, где обычно ломается реализация

Правило прямоугольника с перепутанными углами

Самая частая ошибка. Для буквы из первой строки нужно брать угол в её собственной строке, то есть table[r1][c2], а не table[r2][c1]. При перестановке шифрование внешне работает, но круговой тест не сходится.

Отрицательный остаток при расшифровке

В языках вроде C или JavaScript выражение (0 - 1) % 5 даёт -1, и индекс выходит за границы массива. Python здесь удобнее: его оператор % всегда возвращает неотрицательный результат, поэтому (0 - 1) % 5 равно 4. Если переносите код на другой язык, нормализацию придётся добавить вручную.

Потеря повторяющихся букв в ключе

Проверка if char not in seen обязательна. Без неё ключ «КРИПТОГРАФИЯ» добавит в таблицу вторую Р и вторую И, сетка сдвинется, а последние буквы алфавита в неё не поместятся. Подробнее об этом — в разборе построения таблицы 4 × 8.

Тесты: минимальный набор

Шифр относится к тем задачам, где ошибку легко не заметить: результат выглядит правдоподобно в любом случае. Поэтому тесты здесь не формальность. Минимальный достаточный набор — три проверки:

import unittest


class TestPlayfair(unittest.TestCase):

    def test_reference_english(self):
        """Эталонный пример из учебников: ключ MONARCHY."""
        self.assertEqual(playfair("Hide the gold", "MONARCHY", lang="en"),
                         "BFCKPDFIMPBZ")

    def test_round_trip_russian(self):
        """Шифруем и расшифровываем — должны получить нормализованный текст."""
        key, text = "КРИПТОГРАФИЯ", "Атака на рассвете"
        encrypted = playfair(text, key)
        self.assertEqual(playfair(encrypted, key, decrypt=True),
                         "АТАКАНАРАССВЕТЕХ")

    def test_table_is_complete(self):
        """В таблице ровно 32 клетки и ни одного повтора."""
        flat = [c for row in build_table("КЛЮЧ", "ru") for c in row]
        self.assertEqual(len(flat), 32)
        self.assertEqual(len(set(flat)), 32)


if __name__ == "__main__":
    unittest.main()

Третий тест ловит самую коварную ошибку — потерю буквы при построении таблицы. Без него неверная сетка проходит круговой тест, если обе стороны используют одну и ту же неправильную таблицу, и проблема всплывает только при обмене с чужой реализацией.

Немного истории — и почему это влияет на код

Шифр придумал английский физик Чарльз Уитстон в 1854 году, а популяризировал его друг Лайон Плейфер, чьё имя система и получила. Метод создавался для работы карандашом на бумаге, и это объясняет несколько решений, которые в коде выглядят странно.

Почему отбрасываются цифры и пунктуация? Потому что человек с таблицей 5 × 5 физически не мог их обработать. Почему буквы объединяются попарно? Потому что алфавит должен был уложиться в прямоугольник без пустых клеток. Почему вставляется заполнитель? Потому что правила замены не определены для пары одинаковых букв — они дали бы ту же самую пару.

Уже в 1914 году американский офицер Джозеф Моборн опубликовал метод вскрытия, а немецкая разведка читала британские сообщения к 1915 году. Для современного кода это означает простое правило: реализация годится для учебных задач и головоломок, но не для защиты данных.

Чем эта задача отличается от Цезаря и Виженера

Если вы уже писали шифр Цезаря или шифр Виженера, разница будет ощутимой. Там алфавит остаётся линейным, а вся логика умещается в одну формулу сложения по модулю: (index + shift) % len(alphabet). Позиция буквы — одно число.

У Плейфера позиция — пара координат, а правил замены три вместо одного. Отсюда и словарь positions, и раздельное хранение числа строк и столбцов, и необходимость обрабатывать текст парами, а не посимвольно. Это первый шифр в учебной последовательности, где недостаточно одной строчки арифметики.

Зато взамен появляется устойчивость к простому частотному анализу: шифруются биграммы, и одна и та же буква в разных парах даёт разные символы. На стойкость по современным меркам это, впрочем, не влияет — для защиты реальных данных нужны AES и SHA-256, а не учебные шифры XIX века.

Производительность и Unicode

Алгоритм линеен по длине текста, но в наивной версии есть узкое место: проверка if char not in seen выполняется по списку, то есть за O(n). На 32 буквах это несущественно, однако если вы обобщаете код на большие алфавиты, замените список на dict или set для контроля уникальности.

Отдельная тонкость — Unicode. Русская буква Й может быть записана как один символ U+0419 либо как И плюс комбинирующая краткая U+0306. Визуально они неразличимы, но in для второго варианта вернёт False. Если текст приходит извне, прогоните его через unicodedata.normalize("NFC", text) перед нормализацией.

Что можно добавить

Базовая версия сознательно оставлена минимальной. Для учебного проекта её обычно расширяют так:

  • Тесты на unittest или pytest. Круговой тест плюс эталонный MONARCHY закрывают большую часть ошибок.
  • Интерфейс командной строки через argparse — чтобы передавать ключ и режим параметрами.
  • Автоопределение языка по первому символу текста, попавшему в один из алфавитов.
  • Вывод таблицы рядом с результатом — сильно упрощает отладку вручную.
  • Двухквадратный вариант. Тот же Уитстон предложил схему с двумя таблицами, устраняющую обратимость пар. Немецкая армия применяла её во Второй мировой под названием Handschlüssel. В коде это означает второй вызов build_table и выбор таблицы по позиции буквы в паре.
  • Взломщик. Самое интересное продолжение — алгоритм восхождения на гору: случайная таблица, оценка расшифровки по статистике четырёхбуквенных сочетаний, случайная перестановка, откат при ухудшении. На тексте длиннее расстояния единственности (около 23 символов) он сходится к верной таблице за секунды.

Логичное продолжение темы — написать не шифратор, а взломщик. Как это делается, разобрано в статье как взломать шифр Плейфера: там же объясняется, почему для атаки нужен именно анализ частот биграмм, а не отдельных букв.

Если понадобится посмотреть на байтовое представление шифротекста — например, чтобы передать его в бинарном протоколе, — пригодятся конвертеры текст в HEX и Base64.

Частые вопросы

Почему в коде алфавит записан константой, а не берётся из string.ascii_uppercase?

Стандартная константа содержит все 26 английских букв, а таблице Плейфера нужно ровно 25: буквы I и J делят одну клетку. Для русского языка готовой константы в стандартной библиотеке нет вовсе. Явно записанный алфавит делает соглашение об объединённых буквах видимым в коде, а не спрятанным в логике.

Нужна ли отдельная функция для расшифровки?

Нет. Расшифровка отличается от шифрования только направлением сдвига внутри строки и столбца, поэтому достаточно одного параметра decrypt и переменной shift, принимающей значение 1 или -1. Правило прямоугольника при этом не меняется вовсе: оно само себе обратно.

Почему при разбиении на биграммы используется while, а не срез с шагом 2?

Потому что при встрече двух одинаковых букв подряд вторая буква не поглощается: между ними вставляется заполнитель, а исходная буква переходит в следующую пару. Срез с фиксированным шагом такой сдвиг не умеет — нужен цикл, который сам решает, на сколько шагнуть, на один символ или на два.

Как проверить, что реализация корректна?

Самая надёжная проверка — круговой тест: зашифровать текст, расшифровать результат тем же ключом и сравнить с нормализованным исходником. Дополнительно стоит прогнать классический английский пример с ключом MONARCHY: строка HIDETHEGOLD должна давать BFCKPDFIMPBZ. Если оба теста проходят, таблица и все три правила замены реализованы верно.